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:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,71 @@
# Error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the canonical `STORAGE_HTTP` error namespace used by every module in this package. The codes are produced via `@suaveplan/error.defineCodes` so the resulting string values are guaranteed-unique within the workspace (`"STORAGE_HTTP.NOT_FOUND"`, `"STORAGE_HTTP.UPSTREAM_ERROR"`, …). The companion `StorageHttpError` class extends `SuaveplanError` and adds three diagnostic discriminants — `key`, `httpStatus`, and `url` — so consumers can correlate failures with the offending request without parsing message text.
## Features
- 10 stable error codes covering every HTTP failure mode the adapter raises.
- `StorageHttpError` subclass with `key`, `httpStatus`, and `url` carry-fields.
- Cause-chain preservation via `SuaveplanError`'s `cause` option.
- Type-safe `StorageHttpErrorCode` discriminant inferred from the `defineCodes` tuple.
## Usage: Basic
```ts
import { STORAGE_HTTP_CODES, StorageHttpError } from "@suaveplan/storage-http";
throw new StorageHttpError(
STORAGE_HTTP_CODES.NOT_FOUND,
"Object not found",
{ key: "a/b.bin", httpStatus: 404, url: "https://cdn.example.com/a/b.bin" },
);
```
## Usage: Advanced
```ts
import { STORAGE_HTTP_CODES, StorageHttpError } from "@suaveplan/storage-http";
try {
await storage.get("missing");
} catch (err) {
if (err instanceof StorageHttpError && err.code === STORAGE_HTTP_CODES.UPSTREAM_ERROR) {
// Retry classifier may have already exhausted attempts — surface to the operator.
operatorLog.error({ url: err.url, status: err.httpStatus }, "Upstream failed");
}
throw err;
}
```
## API Reference
### `STORAGE_HTTP_CODES`
Frozen record produced by `defineCodes("STORAGE_HTTP", [...] as const)`. The string values are namespaced — `STORAGE_HTTP_CODES.NOT_FOUND === "STORAGE_HTTP.NOT_FOUND"`.
Codes: `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `NETWORK_ERROR`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `READ_ONLY`, `ABORTED`, `UPSTREAM_ERROR`.
### `StorageHttpErrorCode`
Union of the namespaced code string values.
### `class StorageHttpError extends SuaveplanError<StorageHttpErrorCode>`
- `constructor(code, message, options?)` — `options` accepts `cause`, `details`, `key`, `httpStatus`, `url`.
- `key: string | undefined` — sanitised key on which the failure occurred.
- `httpStatus: number | undefined` — HTTP status when the failure originated from an upstream response.
- `url: string | undefined` — fully-resolved URL the adapter attempted.
- `name = "StorageHttpError"`.
## Implementation Notes
- `STORAGE_HTTP.NOT_FOUND` is raised only when `getPartial` requests a range past EOF (`416 Range Not Satisfiable`). A plain `GET 404` returns `undefined` per `AbstractStorage` contract; it does **not** throw.
- `STORAGE_HTTP.UPSTREAM_ERROR` is the bucket for non-transient `4xx`/`5xx` after retry exhaustion. It is distinct from `NETWORK_ERROR` (fetch reject — DNS/TLS/abort-without-signal) so retry classifiers and operator dashboards can tell them apart.
- `STORAGE_HTTP.READ_ONLY` covers `set`, `delete`, `list`, `clear`, and `createWriteStream` when no `HttpUploader` is wired (or when the op is unconditionally unsupported, e.g. `list`).
- The `key` carry-field stores the **sanitised** key, never the raw caller input — preserving the boundary cleanly.

View File

@@ -0,0 +1,75 @@
# etag-cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory LRU keyed by sanitised storage key, valued by `{ etag, body, lastModified }`. Backs `HttpStorage`'s conditional-GET path so reads of unchanged objects round-trip a single `304 Not Modified` instead of the full body. Capacity defaults to `DEFAULT_ETAG_CACHE_CAPACITY` (256) and is per-instance — cross-process caching is the caller's concern (typically composed via `storage-file` underneath).
## Features
- LRU eviction using `Map` insertion-order semantics — no external dependency.
- `get(key)` is a side-effecting accessor that moves the hit to the MRU position.
- `set(key, entry)` evicts the LRU entry when at capacity.
- `clear()` and `delete(key)` for explicit invalidation.
- `size` and `capacity` getters for tests and operator observability.
## Usage: Basic
```ts
import { EtagCache } from "@suaveplan/storage-http";
const cache = new EtagCache({ capacity: 128 });
cache.set("a/b.bin", { etag: '"v1"', body: bytes, lastModified: undefined });
const hit = cache.get("a/b.bin");
```
## Usage: Advanced
```ts
import { EtagCache } from "@suaveplan/storage-http";
const cache = new EtagCache();
async function conditionalGet(url: string, key: string): Promise<Uint8Array> {
const cached = cache.get(key);
const headers: Record<string, string> = {};
if (cached !== undefined) headers["If-None-Match"] = cached.etag;
const response = await fetch(url, { headers });
if (response.status === 304 && cached !== undefined) return cached.body;
const body = new Uint8Array(await response.arrayBuffer());
const etag = response.headers.get("etag");
if (etag !== null) {
cache.set(key, { etag, body, lastModified: response.headers.get("last-modified") ?? undefined });
}
return body;
}
```
## API Reference
### `class EtagCache`
- `constructor(options?: EtagCacheOptions)` — `options.capacity` defaults to `DEFAULT_ETAG_CACHE_CAPACITY`; values below `1` are clamped to `1`.
- `size: number` — current entry count.
- `capacity: number` — bound supplied at construction.
- `get(key: string): EtagCacheEntry | undefined` — moves hit to MRU; returns `undefined` on miss.
- `has(key: string): boolean` — non-side-effecting existence check.
- `set(key: string, entry: EtagCacheEntry): void` — insert / replace; evicts LRU when full.
- `delete(key: string): boolean` — returns `true` when an entry was removed.
- `clear(): void` — drops every entry.
### `interface EtagCacheEntry`
- `etag: string` — verbatim from `ETag` response header (caller decides whether to strip weak prefix `W/`).
- `body: Uint8Array` — cached body.
- `lastModified: string | undefined` — verbatim `Last-Modified` header when present.
## Implementation Notes
- LRU via insertion-order `Map` mutation is the cheapest correct implementation in JS without a doubly-linked list. The `get` hit path is two `Map` mutations (delete + set) but stays O(log n) due to the underlying hashtable.
- `capacity` is clamped at construction; a caller passing `0` ends up with a 1-entry cache (degenerate but defined). Passing a negative number is a programmer error but is silently corrected to `1` rather than throwing — the cache is best-effort by design.
- Cache invalidation on writes is the caller's responsibility — when `HttpStorage.set` succeeds, it calls `delete(key)` so the next read re-fetches. The cache itself has no awareness of writes.
- Body storage uses the `Uint8Array` reference verbatim; callers that need defensive copies must slice before inserting.

View File

@@ -0,0 +1,63 @@
# fetch-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps `globalThis.fetch` with the contract `HttpStorage` actually consumes. Translates raw network rejections (DNS failure, TLS error, connection reset, abort-without-signal) into a structured `StorageHttpError` carrying code `STORAGE_HTTP.NETWORK_ERROR` and the offending URL. `AbortError` is re-raised unchanged so the per-method wrapper in `HttpStorage` can map signal-driven cancellation to `STORAGE_HTTP.ABORTED`.
## Features
- Single function `defaultFetch()` returns a `FetchLike` bound to the global `fetch`.
- `NETWORK_ERROR` mapping covers every kind of `fetch` reject that isn't an abort.
- URL is extracted from `string`, `URL`, or `Request` inputs and attached to the error for diagnostics.
- `isAbortError(err)` exposed for callers that need to distinguish abort from other failures without re-implementing the `DOMException`-shaped check.
## Usage: Basic
```ts
import { defaultFetch } from "@suaveplan/storage-http";
const fetch = defaultFetch();
const response = await fetch("https://cdn.example.com/file.bin");
```
## Usage: Advanced
```ts
import { defaultFetch, isAbortError, StorageHttpError, STORAGE_HTTP_CODES } from "@suaveplan/storage-http";
const fetch = defaultFetch();
try {
const response = await fetch("https://cdn.example.com/file.bin", { signal });
if (!response.ok) handleStatus(response.status);
} catch (err) {
if (isAbortError(err)) return; // caller-driven cancellation
if (err instanceof StorageHttpError && err.code === STORAGE_HTTP_CODES.NETWORK_ERROR) {
operatorLog.error({ url: err.url, cause: err.cause }, "Origin unreachable");
}
throw err;
}
```
## API Reference
### `defaultFetch(): FetchLike`
Returns a `FetchLike` (`(input, init?) => Promise<Response>`) that:
- Forwards to `globalThis.fetch` unchanged on success.
- Re-raises `AbortError` unchanged for the caller's wrapper.
- Maps every other `fetch` reject to `StorageHttpError(STORAGE_HTTP.NETWORK_ERROR)` with the resolved URL and the original error as `cause`.
### `isAbortError(err: unknown): boolean`
Returns `true` when `err` is a non-null object with `name === "AbortError"`. Runtime-neutral — works for both `DOMException` (browsers, Workers) and Node 18+'s native `AbortError`.
## Implementation Notes
- The adapter is intentionally thin. Status-code mapping (404 → `undefined`, 401 → `UNAUTHORIZED`, etc.) belongs to `HttpStorage`'s per-method wrappers, not here, so this module stays usable for callers that want raw status access (e.g. a custom listing implementation).
- URL extraction handles all three documented `fetch` input shapes. A pathological input that is none of them returns `undefined` URL on the error — the message still names the failure type.
- `globalThis.fetch` is required (Node 18+, Bun, Deno, modern browsers, Workers). The package does not ship a polyfill — that would violate tier purity.

View File

@@ -0,0 +1,82 @@
# http-storage.helpers
## Purpose
Internal helpers used by `HttpStorage`. Splits boundary concerns (option parsing, URL composition, header resolution, abort guards, status → error mapping) out of the public class so the latter stays close to 250 lines and the helpers can be unit-tested in isolation.
## Features
- `resolveHttpStorageConfig(options)` — parses options through `HttpStorageOptionsSchema`, throws `STORAGE_HTTP.INVALID_OPTIONS` on validation failure, and pre-resolves `fetch` / `keyEncoder` defaults.
- `composeUrl(config, sanitisedKey)` — applies `keyEncoder` and `new URL(encoded, baseUrl)` in one call.
- `resolveHeaders(resolver, key, op)` — static record or async per-op callback.
- `throwIfAborted(signal)` — pre-fetch abort guard producing `STORAGE_HTTP.ABORTED`.
- `errorForStatus(status, url, key)` — maps `401`/`403`/`404`/`410`/other to the canonical `StorageHttpError`.
- `outcomeFromError(err)` — translates any thrown error to the `{ outcome, statusClass }` pair the OTel wrapper records.
## Usage: Basic
```ts
import { resolveHttpStorageConfig, composeUrl } from "@suaveplan/storage-http";
const config = resolveHttpStorageConfig({ baseUrl: "https://cdn.example.com/start/here/" });
const url = composeUrl(config, "a/b/file.bin");
```
## Usage: Advanced
```ts
import { errorForStatus, outcomeFromError, resolveHeaders, throwIfAborted } from "@suaveplan/storage-http";
async function customGet(config, key, signal) {
throwIfAborted(signal);
const headers = await resolveHeaders(config.headers, key, "get");
const response = await config.fetch(composeUrl(config, key), { headers, signal });
if (!response.ok) {
const err = errorForStatus(response.status, composeUrl(config, key), key);
const { outcome, statusClass } = outcomeFromError(err);
recordOp({ op: "get", outcome, statusClass, durationMs: 0 });
throw err;
}
return new Uint8Array(await response.arrayBuffer());
}
```
## API Reference
### `resolveHttpStorageConfig(options): ResolvedHttpStorageConfig`
Throws `StorageHttpError(STORAGE_HTTP.INVALID_OPTIONS)` with one message per Zod issue. Returns `{ baseUrl, headers, fetch, keyEncoder, options }`.
### `composeUrl(config, sanitisedKey): string`
Encodes the sanitised key via `config.keyEncoder`, resolves against `config.baseUrl`.
### `resolveHeaders(resolver, key, op): Promise<Record<string, string>>`
- `undefined` → `{}`.
- Static record → shallow copy.
- Function → awaited, then shallow-copied.
### `throwIfAborted(signal?): void`
Throws `StorageHttpError(STORAGE_HTTP.ABORTED)` when `signal?.aborted === true`.
### `errorForStatus(status, url, key): StorageHttpError`
| status | code |
|---|---|
| 401 | `UNAUTHORIZED` |
| 403 | `FORBIDDEN` |
| 404, 410 | `NOT_FOUND` |
| other | `UPSTREAM_ERROR` |
### `outcomeFromError(err): { outcome, statusClass }`
`StorageHttpError` → maps by `code`; anything else → `{ outcome: "upstream_error", statusClass: "none" }`.
## Implementation Notes
- The error-status table intentionally collapses `404` and `410` to `NOT_FOUND`. `410 Gone` is semantically distinct, but storage callers treat both as "no value" — distinguishing them would force every caller to handle a second code path with no behavioural difference.
- `outcomeFromError` returns the wider `outcome` enum used by `recordOp`. Adding a new error code requires extending the switch — every code MUST map deterministically so the 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.
- `composeUrl` relies on `new URL(rel, base)` enforcing the trailing-slash invariant. Because the schema rejects non-`/`-terminated `baseUrl`, the function cannot silently truncate a path segment.

View File

@@ -0,0 +1,94 @@
# http-storage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HttpStorage` is the universal `AbstractStorage` adapter that maps every storage operation onto an HTTP request against a configured `baseUrl + key`. It covers the full read surface (`GET`, `HEAD`, `Range`, native streaming, ETag-cached conditional reads) and gates write operations behind an injectable `HttpUploader` — without one, `set`, `delete`, `setPartial`, and `createWriteStream` throw `STORAGE_HTTP.READ_ONLY`. `list`, `clear`, and `getSize` always throw `READ_ONLY` because HTTP origins do not expose a uniform directory protocol.
The class is the natural binding for static-site CDNs (Cloudflare, Fastly), prefix-readable S3 buckets, public BunnyCDN Pull Zones, GitHub Pages, Caddy file servers, and similar origins. Provider-specific adapters (Rackspace, BunnyCDN, R2) layer on top via the `HttpUploader` slot.
## Features
- Full `AbstractStorage` shape — `get`, `set` (with uploader), `exists`, `getMetadata`, `getWithMetadata`, `getItemSize`, `getPartial`, `createReadStream`, `createWriteStream`, `delete`, `setPartial`.
- `capabilities` declares `partialReads: true`, `nativeStreams: true`, `atomicWrites: false`, `partialWrites: false`, `contentAddressable: false`.
- AbortSignal threaded into every fetch; pre-aborted signal short-circuits before any network call.
- Optional ETag-cached conditional reads — `If-None-Match` attached automatically, `304` returns cached body.
- HTTP `Range` reads with `206`/`200`/`416` disambiguation and a counter for misconfigured origins.
- Native `ReadableStream` wrapping `Response.body` — no client-side buffering.
- Retry on transient outcomes (`408`/`425`/`429`/`5xx` and network rejection) honouring `Retry-After`.
- Full OTel surface — duration / IO bytes histograms; per-op outcome counters; spans per call.
## Usage: Basic
```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");
```
## Usage: Advanced
```ts
import { HttpStorage, presignedPutUploader } from "@suaveplan/storage-http";
const storage = new HttpStorage({
baseUrl: "https://cdn.example.com/start/here/",
headers: (key, op) =>
op === "set"
? { Authorization: `Bearer ${signedToken()}`, "Content-Type": "application/octet-stream" }
: { Authorization: `Bearer ${readToken()}` },
uploader: presignedPutUploader({ signer: presignFor(key, "PUT") }),
etagCache: { capacity: 1024 },
retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10_000, honourRetryAfter: true },
});
const controller = new AbortController();
const stream = storage.createReadStream("video.mp4", controller.signal);
for await (const chunk of stream) processChunk(chunk);
```
## API Reference
### `class HttpStorage extends AbstractStorage`
- `constructor(options: HttpStorageOptions)` — validates `options` via `HttpStorageOptionsSchema`; throws `STORAGE_HTTP.INVALID_OPTIONS` on failure (notably missing trailing slash on `baseUrl`).
- `capabilities: StorageCapabilities` — frozen per the matrix above.
Read methods:
- `get(key, signal?): Promise<Uint8Array | undefined>` — `404`/`410` → `undefined`; `4xx` → mapped error; transient → retried.
- `exists(key, signal?): Promise<boolean>` — HEAD-backed.
- `getMetadata(key, signal?): Promise<StorageMetadata | null>` — parses `Content-Length`, `Last-Modified`.
- `getWithMetadata`, `getItemSize` — composition over `get` + `getMetadata`.
- `getPartial(key, offset, length, signal?): Promise<Uint8Array | null>` — Range with 206/200/416 handling.
- `createReadStream(key, signal?): ReadableStream<Uint8Array>` — wraps `Response.body`.
Write methods (require `uploader`):
- `set(key, value, options?, signal?): Promise<void>` — calls `uploader.put`; invalidates ETag cache entry.
- `delete(key, signal?): Promise<boolean>` — requires `uploader.delete`; throws `READ_ONLY` otherwise.
- `setPartial` — read-modify-write via `defaultSetPartial`.
- `createWriteStream(key, signal?): WritableStream<Uint8Array>` — buffers, calls `uploader.put` on `close`.
Always-`READ_ONLY`:
- `list`, `clear`, `getSize` — HTTP origins do not standardise these.
### `createHttpStorage(options): HttpStorage`
Convenience factory mirroring `createNodeFsStorage`.
## Implementation Notes
- `getWithMetadata` issues two requests (`GET` + `HEAD`). Operators that prefer one round-trip should call `get` and synthesise metadata from the response headers (the ETag cache stores `lastModified` for exactly this reason).
- `setPartial` only proceeds when an uploader is wired; it then delegates to `AbstractStorage.defaultSetPartial` which is O(N) memory per call. Backends with native range writes belong in a different adapter.
- `createWriteStream` collects to memory before calling `uploader.put`. HTTP origins generally don't support truly chunked PUTs without provider-specific multipart logic, and the spec scoped that out.
- ETag cache invalidation is unconditional on `set` / `delete` / `createWriteStream.close` — the cache must never hold stale bodies after a write.
- All non-`ok` HEAD responses go through `upstreamError` so the `STORAGE_HTTP.UPSTREAM_ERROR` code is consistently raised on the metadata path.
- The `_options` parameter on `set` is currently unused — `StorageOptions.ttl` / `compress` / `encrypt` have no HTTP semantics, and `metadata` belongs in `headers` via the caller's resolver.

View File

@@ -0,0 +1,92 @@
# http-storage.ops
## Purpose
Reusable read-side operations used by `HttpStorage`: full-body `GET` with ETag-cache support, `HEAD` for metadata, `Range`-based partial reads, and streaming `GET`. Each helper takes a `ReadContext` (config + cache + key + signal + retry options) and returns the result; the public class methods compose them with the OTel wrapper and signal guards.
Split out so tests can exercise each path in isolation without setting up a full `HttpStorage` instance.
## Features
- `performGet(ctx)` — full-body GET, ETag-cache aware (sends `If-None-Match` on hit, returns cached body on `304`), retries transient outcomes, maps non-`ok` status to `errorForStatus`.
- `performHead(ctx)` — HEAD with retry.
- `performGetPartial(ctx, offset, length)` — issues Range, delegates 200/206/416 disambiguation to `consumeRangeResponse`, increments range-fallback counter on `200`.
- `performGetStream(ctx)` — single GET without ETag-cache, returns the raw `Response` so the caller can pump `body.getReader()`.
- `fetchWithSignal(ctx, url, method, headers)` — low-level wrapper that attaches the signal to `init`.
- `parseMetadata(response)` — extracts `StorageMetadata` from HEAD response headers.
- `readOnlyError(method, key?)` — canonical `STORAGE_HTTP.READ_ONLY` builder.
## Usage: Basic
```ts
import { performGet } from "@suaveplan/storage-http";
const ctx = { config, cache: undefined, key: "file.bin", signal, retryOptions: {} };
const body = await performGet(ctx);
```
## Usage: Advanced
```ts
import {
composeUrl,
fetchWithSignal,
performGetPartial,
performHead,
parseMetadata,
readOnlyError,
} from "@suaveplan/storage-http";
async function existsAndMeta(ctx): Promise<StorageMetadata | null> {
const response = await performHead(ctx);
if (response.status === 404) return null;
return parseMetadata(response);
}
async function partialReader(ctx, offset: number, length: number) {
const bytes = await performGetPartial(ctx, offset, length);
if (bytes === null) throw readOnlyError("getPartial", ctx.key);
return bytes;
}
```
## API Reference
### `interface ReadContext`
`{ config, cache, key, signal, retryOptions }` — the bundle every read helper consumes.
### `performGet(ctx): Promise<Uint8Array | undefined>`
Returns `undefined` on `404`/`410`, the cached body on `304`, the response bytes otherwise. Updates the cache on `200` when an `ETag` is present.
### `performHead(ctx): Promise<Response>`
Returns the raw response. Callers inspect `status` and headers themselves.
### `performGetPartial(ctx, offset, length): Promise<Uint8Array | null>`
Returns `null` on `404`/`410`, throws on `401`/`403`/non-2xx without Range fallback, otherwise returns the slice via `consumeRangeResponse`.
### `performGetStream(ctx): Promise<Response>`
Throws `errorForStatus` on non-`ok` responses; caller is responsible for consuming `body`.
### `fetchWithSignal(ctx, url, method, headers): Promise<Response>`
Thin wrapper that calls `config.fetch(url, init)` with the signal attached when present.
### `parseMetadata(response): StorageMetadata`
Extracts `Content-Length` (defaulting `size` to 0 when absent or unparsed), `Last-Modified` (defaulting to `Date.now()` when missing). Returns `createdAt === modifiedAt` because HTTP HEAD does not surface creation time.
### `readOnlyError(method, key?): StorageHttpError`
Builds the canonical `STORAGE_HTTP.READ_ONLY` error with a documented message.
## Implementation Notes
- `performGet` is the only helper that touches the ETag cache; `performGetPartial` and `performGetStream` deliberately bypass it because partial reads and streams complicate the caching contract (you would have to cache slices, not the whole body).
- The 401/403 short-circuit in `performGetPartial` is deliberate — non-`ok` non-range statuses should surface as auth errors before `consumeRangeResponse` complains about the body not being a partial.
- `parseMetadata` returns a usable shape even when the origin omits `Content-Length` (S3 with chunked transfer, some edge caches). Callers that need exact size must call `getItemSize` against an origin that does set the header.
- `readOnlyError` carries the sanitised key when known so the `key` field on the resulting error matches the key the caller would see in success cases.

View File

@@ -0,0 +1,67 @@
# http-storage.streams
## Purpose
Builds the `ReadableStream` and `WritableStream` instances `HttpStorage` returns from `createReadStream` / `createWriteStream`. Splits the stream plumbing out of the class body so each side can be unit-tested without instantiating the public adapter.
`buildReadStream` wraps `Response.body` directly — no client-side buffering — so callers see the upstream chunk boundaries. `buildWriteStream` buffers chunks until `close` and then dispatches `uploader.put` with the concatenated body; without an uploader it throws `STORAGE_HTTP.READ_ONLY` synchronously at construction.
## Features
- Native streaming: `buildReadStream` returns `Response.body` chunks 1:1 to the consumer.
- Cancellation: errors mid-stream surface via `controller.error`; aborts propagate through the underlying fetch's signal.
- Synchronous READ_ONLY enforcement: `buildWriteStream` throws before returning the stream so callers don't observe a half-built object.
- `abort()` handler drops buffered chunks promptly so memory does not linger after cancellation.
- `upstreamError` helper used by `HttpStorage` for non-`ok` HEAD/GET responses that aren't already mapped by `errorForStatus`.
## Usage: Basic
```ts
import { buildReadStream, buildWriteStream } from "@suaveplan/storage-http";
const stream = buildReadStream(ctx, signal);
for await (const chunk of stream) process(chunk);
```
## Usage: Advanced
```ts
import { buildReadStream, buildWriteStream, upstreamError } from "@suaveplan/storage-http";
const write = buildWriteStream({
uploader,
key,
resolveHeaders: () => resolveHeaders(headersResolver, key, "set"),
signal,
onWritten: (bytes) => emitOtel(bytes),
});
const writer = write.getWriter();
await writer.write(chunkA);
await writer.write(chunkB);
await writer.close();
```
## API Reference
### `buildReadStream(ctx: ReadContext, signal?: AbortSignal): ReadableStream<Uint8Array>`
Starts the underlying `GET` on first read. Errors propagate through `controller.error`. `Response.body === null` closes the stream cleanly without enqueueing.
### `interface WriteStreamArgs`
`{ uploader, key, resolveHeaders, signal, onWritten }` — `resolveHeaders` is invoked once at `close` time so the request gets the latest token from the caller's resolver.
### `buildWriteStream(args: WriteStreamArgs): WritableStream<Uint8Array>`
Throws `STORAGE_HTTP.READ_ONLY` immediately when `args.uploader` is undefined. On `close`, concatenates buffered chunks, calls `uploader.put`, then invokes `onWritten(bytes)` for OTel emission and ETag-cache invalidation.
### `upstreamError(method, url, key, status): StorageHttpError`
`STORAGE_HTTP.UPSTREAM_ERROR` with the method/url/key/status carried as discriminants.
## Implementation Notes
- Buffered-then-flush is the right tradeoff for HTTP `PUT`: real chunked uploads require provider-specific multipart logic (S3 / R2 / GCS) which is out of scope for the core adapter. Provider packages may override `createWriteStream`.
- `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.
- `onWritten` is invoked **after** `uploader.put` resolves so the cache invalidation never runs against an in-flight write.

View File

@@ -0,0 +1,72 @@
# key-sanitizer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates and normalises caller-supplied storage keys before they reach any `fetch` call, and provides the default per-segment URL encoder. Keys must be POSIX-form relative paths — no `..`, no leading `/`, no Windows drive prefixes, no control characters, no nulls, no values longer than `MAX_KEY_LENGTH` (2048 chars). Rejections throw `StorageHttpError` with code `STORAGE_HTTP.INVALID_KEY` or `STORAGE_HTTP.KEY_TOO_LONG`.
Sanitisation runs **before** every public method issues a fetch. This means a misbehaved caller can never observe network traffic for an invalid key, and the `key` carried on the resulting error is the original input for debugging.
## Features
- Strict validation: empty, traversal, absolute, drive-letter, null byte, control char, overlong.
- Normalisation: Windows `\` → POSIX `/` (idempotent on already-POSIX input).
- Distinct error code for length overflow (`KEY_TOO_LONG`) vs. content issues (`INVALID_KEY`).
- Default key encoder applies `encodeURIComponent` per `/`-separated segment, preserving slashes.
## Usage: Basic
```ts
import { defaultKeyEncoder, sanitizeKey } from "@suaveplan/storage-http";
const safe = sanitizeKey("a/b/file name.txt");
// → "a/b/file name.txt"
const encoded = defaultKeyEncoder(safe);
// → "a/b/file%20name.txt"
```
## Usage: Advanced
```ts
import { defaultKeyEncoder, sanitizeKey, StorageHttpError, STORAGE_HTTP_CODES } from "@suaveplan/storage-http";
function safeUrl(baseUrl: string, key: string): URL {
let normalised: string;
try {
normalised = sanitizeKey(key);
} catch (err) {
if (err instanceof StorageHttpError && err.code === STORAGE_HTTP_CODES.KEY_TOO_LONG) {
// Caller can retry with a hashed key.
return safeUrl(baseUrl, hashKey(key));
}
throw err;
}
return new URL(defaultKeyEncoder(normalised), baseUrl);
}
```
## API Reference
### `sanitizeKey(key: string): string`
Throws `StorageHttpError` with code:
- `STORAGE_HTTP.INVALID_KEY` — empty, non-string, control char, leading `/`, drive prefix, or `..` segment.
- `STORAGE_HTTP.KEY_TOO_LONG` — length > `MAX_KEY_LENGTH` (2048).
Returns the input with Windows `\` replaced by `/`.
### `defaultKeyEncoder(key: string): string`
Splits on `/`, runs `encodeURIComponent` per segment, re-joins. Preserves path hierarchy.
## Implementation Notes
- Path traversal is checked after `\` normalisation so `a\..\b` is rejected.
- Drive prefix detection accepts `[A-Za-z]:[\\/]` — both `C:\` and `C:/` reject.
- Control-char check scans every code unit; the early-exit loop is O(n) with one pass.
- The sanitiser does not URL-encode — encoding is a separate concern handled by `defaultKeyEncoder` (or a caller-supplied alternative for CDNs whose signed-URL signatures require custom encoding rules).
- Long-key error message includes a truncated prefix (`first 64 chars + ellipsis`) so the discriminant doesn't bloat logs.

View File

@@ -0,0 +1,84 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument set for `@suaveplan/storage-http`: counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`, `range_fallback_total`), histograms (`op_duration_ms`, `io_bytes`), and a tracer scoped to `@suaveplan/storage-http`. Every public operation in `HttpStorage` opens a span and calls `recordOp` so operators can observe per-status-class latency and per-outcome counts without instrumenting consumer code.
The module is intentionally pluggable — `setStorageHttpMeterProvider` lets tests bind to an `InMemoryMetricExporter` while production binds to whatever the host application has configured globally.
## Features
- Lazy instrument construction so consumers can install a custom MeterProvider before the first operation runs.
- `setStorageHttpMeterProvider(provider)` override hook with auto-rebuild on next access.
- `recordOp({ op, outcome, statusClass, durationMs, bytes?, direction? })` emits the full observation set for one operation.
- `recordRangeFallback()` increments the `range_fallback_total` counter exactly once — surfaces misconfigured origins.
- `statusClassOf(status)` maps numeric status to the `2xx`/`3xx`/`4xx`/`5xx`/`none` attribute used on histograms.
- `resetInstruments()` for test isolation.
## Usage: Basic
```ts
import { recordOp } from "@suaveplan/storage-http";
recordOp({ op: "get", outcome: "ok", statusClass: "2xx", durationMs: 42, bytes: 1024, direction: "read" });
```
## Usage: Advanced
```ts
import { setStorageHttpMeterProvider, resetInstruments, recordOp } from "@suaveplan/storage-http";
import { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from "@opentelemetry/sdk-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
setStorageHttpMeterProvider(provider);
resetInstruments();
recordOp({ op: "get", outcome: "ok", statusClass: "2xx", durationMs: 10 });
await reader.collect();
```
## API Reference
### `setStorageHttpMeterProvider(provider?: MeterProvider): void`
Override or restore the meter provider. Calling this invalidates the cached instrument set so the next `getInstruments()` rebinds.
### `getInstruments(force?: boolean): StorageHttpInstruments`
Returns the cached instrument bundle (`tracer`, `meter`, every counter and histogram). `force = true` rebuilds.
### `recordOp(input: RecordOpInput): void`
`input.outcome` decides which counter increments:
- `"ok"` + read-ish op → `reads_total`.
- `"ok"` + `set`/`setPartial` → `writes_total`.
- `"ok"` + `delete`/`clear` → `deletes_total`.
- anything else → `errors_total`.
Always records `op_duration_ms{op, outcome, status_class}`. When `bytes` and `direction` are both provided, also records `io_bytes{op, direction}`.
### `recordRangeFallback(): void`
Increments `range_fallback_total{outcome="ok"}`.
### `statusClassOf(status: number | undefined): StatusClass`
`"2xx" | "3xx" | "4xx" | "5xx" | "none"`. `"none"` covers `undefined` and out-of-range inputs.
### `resetInstruments(): void`
Drops the cached bundle without changing the meter provider.
## Implementation Notes
- The cache is critical: instrument creation is not free and consumers call `recordOp` in the hot path. `setStorageHttpMeterProvider` is the one path that intentionally invalidates the cache, so test isolation is straightforward.
- `status_class` is an attribute on `op_duration_ms` rather than a separate histogram so operators can break down latency by class without exploding cardinality.
- `range_fallback_total` deliberately carries `outcome="ok"` only — the fallback path always succeeds (just less efficiently). If the underlying request fails, that surfaces via `errors_total` instead.
- The `direction` attribute is only set for `set` / `setPartial` / `get` / `getPartial` — operations like `exists` or `delete` carry no bytes.
- The tracer is acquired separately from the meter; no caching is required because `trace.getTracer` is idempotent.

View File

@@ -0,0 +1,63 @@
# range
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes HTTP `Range` semantics for `HttpStorage.getPartial`. Builds the request header from `(offset, length)` and interprets the response in the three documented shapes: `206 Partial Content` (happy path — body returned verbatim), `200 OK` (origin ignored the Range — body sliced client-side and the fallback is flagged so the caller can emit a counter), and `416 Range Not Satisfiable` (request beyond EOF — mapped to `STORAGE_HTTP.NOT_FOUND`).
Without this module, every adapter consumer would have to re-implement the same disambiguation. Keeping it isolated also lets tests pin range behaviour without spinning up the full `HttpStorage`.
## Features
- `buildRangeHeader(offset, length)` — validates integer non-negativity / positivity, returns `bytes=<o>-<o+l-1>`.
- `consumeRangeResponse(response, offset, length, url)` — distinguishes 206 / 200 / 416 / other, returns `{ bytes, fellBack }`.
- Programmer-error inputs throw `STORAGE_HTTP.INVALID_OPTIONS` so they surface in development, not as cryptic upstream failures.
## Usage: Basic
```ts
import { buildRangeHeader, consumeRangeResponse } from "@suaveplan/storage-http";
const header = buildRangeHeader(0, 1024); // "bytes=0-1023"
const response = await fetch(url, { headers: { Range: header } });
const { bytes, fellBack } = await consumeRangeResponse(response, 0, 1024, url);
```
## Usage: Advanced
```ts
import { buildRangeHeader, consumeRangeResponse } from "@suaveplan/storage-http";
async function readSlice(url: string, offset: number, length: number) {
const response = await fetch(url, { headers: { Range: buildRangeHeader(offset, length) } });
const { bytes, fellBack } = await consumeRangeResponse(response, offset, length, url);
if (fellBack) operatorLog.warn({ url }, "origin ignored Range — full body returned");
return bytes;
}
```
## API Reference
### `buildRangeHeader(offset: number, length: number): string`
Returns `"bytes=<offset>-<offset+length-1>"`. Throws `StorageHttpError(INVALID_OPTIONS)` when `offset` is not a non-negative integer or `length` is not a positive integer.
### `interface RangeOutcome`
`{ bytes: Uint8Array; fellBack: boolean }` — `fellBack` is `true` only when the origin returned `200 OK` to a `Range` request and the slice was performed client-side.
### `consumeRangeResponse(response, offset, length, url): Promise<RangeOutcome>`
- `206` → returns the body verbatim with `fellBack = false`.
- `200` → consumes the full body, slices `[offset, offset + length)`, returns with `fellBack = true`.
- `416` → throws `StorageHttpError(NOT_FOUND)` with `httpStatus: 416`.
- other → throws `StorageHttpError(UPSTREAM_ERROR)` with the observed status.
## Implementation Notes
- The `Content-Range` response header is intentionally NOT parsed here — it carries the total object size, which is useful for cache metadata but not required to decode the body. `HttpStorage` parses it in the metadata path when needed.
- Client-side slicing on `200` uses `Math.min` clamps so out-of-bounds requests never throw `RangeError` on `slice`; the resulting array is simply empty if `offset >= body.byteLength`. The caller decides whether to treat that as "object shorter than expected" or pass it through.
- `416` mapping to `NOT_FOUND` is a deliberate simplification: distinguishing "object missing" from "range past EOF on an existing object" requires a `HEAD` round-trip the caller already has cheaper alternatives for (e.g. `exists()` or attaching `If-Range` to disambiguate cache vs miss).

View File

@@ -0,0 +1,77 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `http` slot, and exposes `registerHttpProvider()` to bind `HttpStorage` as the constructor for that slot. Consumers who prefer factory-style instantiation (`createStorage({ type: "http", config })`) call `registerHttpProvider()` 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<"http">` now type-checks with `HttpStorageOptions`.
- Idempotent `registerHttpProvider()` — safe to call from multiple bootstrap paths.
- Zero runtime dependencies beyond `@suaveplan/storage/factory` and the local `HttpStorage` class.
## Usage: Basic
```ts
import { registerHttpProvider } from "@suaveplan/storage-http";
import { createStorage } from "@suaveplan/storage/factory";
registerHttpProvider();
const storage = createStorage({
type: "http",
config: { baseUrl: "https://cdn.example.com/start/here/" },
});
```
## Usage: Advanced
```ts
import { registerHttpProvider, presignedPutUploader } from "@suaveplan/storage-http";
import { createStorage } from "@suaveplan/storage/factory";
// Bootstrap once for the process.
registerHttpProvider();
function makeStorage(zone: string) {
return createStorage({
type: "http",
config: {
baseUrl: `https://${zone}.b-cdn.net/`,
uploader: presignedPutUploader({ signer: presignFor(zone) }),
},
});
}
```
## API Reference
### `registerHttpProvider(): void`
Registers `HttpStorage` against `StorageFactory` under the `"http"` slot if not already registered. Calling it twice is a no-op.
### Module augmentation
```ts
declare module "@suaveplan/storage/factory" {
interface StorageProviderRegistry { http: HttpStorage }
interface StorageProviderConfig { http: HttpStorageOptions }
}
```
This means:
- `createStorage<"http">(...)` returns `HttpStorage`.
- The `config` parameter is typed as `HttpStorageOptions`.
## Implementation Notes
- The cast `HttpStorage as unknown as new (config?: unknown) => HttpStorage` is required because the factory'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("http")` is the idempotency gate. After `unregisterStorageProvider("http")` or `clearStorageProviders()` from `@suaveplan/storage`, a subsequent `registerHttpProvider()` call cleanly re-registers.
- The augmentation is `interface` merging — pure type-level. There is no runtime cost beyond the one-time `Map.set` in the factory.
- This module imports `HttpStorage` directly rather than via the package root barrel to avoid a circular import (the root barrel re-exports `registerHttpProvider`).

View File

@@ -0,0 +1,79 @@
# retry-helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Coordinates retry logic for HTTP requests issued by `HttpStorage`. Distinguishes transient outcomes (HTTP `408`/`425`/`429`/`5xx` or `fetch`-level network rejection) 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, never blocked on a timer.
The helper exists because two of the spec's requirements (`Retry-After` honour and the precise classifier) are too provider-specific to delegate to `@suaveplan/retry` directly — the latter is generic across error types, while this module knows about HTTP semantics.
## Features
- `isTransientStatus(status)` — `408`, `425`, `429`, or any `5xx`.
- `isTransientError(err)` — `StorageHttpError(NETWORK_ERROR)` produced by the `fetch` adapter.
- `parseRetryAfter(value, now)` — supports delta-seconds and HTTP-date forms; caps at `RETRY_AFTER_MAX_DELAY_MS` (60s) to defend against malicious origins.
- `retryWithBackoff(fn, options)` — runs `fn` up to `maxAttempts`, classifies outcomes, sleeps between attempts, supports cancellation.
- Injectable `TimersLike` so tests pin behaviour under a `FakeClock` without real timers.
## Usage: Basic
```ts
import { retryWithBackoff } from "@suaveplan/storage-http";
const response = await retryWithBackoff(() => fetch("https://cdn.example.com/file.bin"), {
retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 5_000 },
});
```
## Usage: Advanced
```ts
import { retryWithBackoff, isTransientStatus, parseRetryAfter } from "@suaveplan/storage-http";
import { fakeClock } from "@suaveplan/testing/runner";
const clock = fakeClock();
const response = await retryWithBackoff(
async () => fetch("https://cdn.example.com/file.bin", { signal }),
{
retry: { maxAttempts: 3, honourRetryAfter: true },
timers: clock.timers,
signal,
},
);
```
## API Reference
### `retryWithBackoff(fn, options?): Promise<Response>`
- `fn: () => Promise<Response>` — attempts the request.
- `options.retry?` — `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `honourRetryAfter` (default `true`).
- `options.timers?` — defaults to `globalThis.setTimeout`/`clearTimeout`.
- `options.signal?` — cancels the wait between attempts; pre-aborted signal short-circuits with `STORAGE_HTTP.ABORTED`.
Returns the last response after exhaustion (caller decides how to surface a transient final status) or throws the captured error.
### `isTransientStatus(status: number): boolean`
True for `408`, `425`, `429`, or any value in `[500, 599]`.
### `isTransientError(err: unknown): boolean`
True for `StorageHttpError` with code `STORAGE_HTTP.NETWORK_ERROR`.
### `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
- `5xx` is intentionally handled as a range check, not as set membership, so the constants module doesn't need 100 entries.
- The `Retry-After` cap is **distinct from** `maxDelayMs`; the latter governs exponential backoff, the former specifically defends against the "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; this is deliberate. HTTP semantics drive every retry decision, and a generic helper would either obscure the classifier or duplicate it.
- Exhaustion returns the last response (when one was received) rather than throwing — `HttpStorage`'s wrapper is the right place to map a final `5xx` to `STORAGE_HTTP.UPSTREAM_ERROR` because it has the URL and key context.

View File

@@ -0,0 +1,74 @@
# etag-cache.types
## Purpose
Declares the configuration and entry types used by the in-memory `EtagCache`
that backs `HttpStorage`'s conditional-GET path. `EtagCacheOptions` is the
public configuration the consumer passes via `HttpStorageOptions.etagCache`;
`EtagCacheEntry` is the internal record the cache stores per key.
## Features
- Single configuration knob (`capacity`) — keeps the cache contract tiny.
- Optional fields throughout — every option has a sensible default.
- `EtagCacheEntry` carries `lastModified` alongside `etag` and `body` so
`getMetadata` can serve from cache without an extra HEAD.
## Usage: Basic
```ts
import type { EtagCacheOptions } from "@suaveplan/storage-http";
const options: EtagCacheOptions = { capacity: 256 };
```
## Usage: Advanced
```ts
import type {
EtagCacheEntry,
EtagCacheOptions,
} from "@suaveplan/storage-http";
import { EtagCache } from "@suaveplan/storage-http";
const cache = new EtagCache({ capacity: 1024 } satisfies EtagCacheOptions);
const entry: EtagCacheEntry = {
etag: '"v1"',
body: new Uint8Array([1, 2, 3]),
lastModified: new Date().toUTCString(),
};
cache.set("a/b.bin", entry);
```
## API Reference
### `interface EtagCacheOptions`
- `capacity?: number` — maximum entries kept simultaneously. Defaults to
`DEFAULT_ETAG_CACHE_CAPACITY` (256). Values < 1 are clamped to 1.
### `interface EtagCacheEntry`
- `etag: string` — verbatim from the `ETag` response header.
- `body: Uint8Array` — cached body.
- `lastModified: string | undefined` — verbatim from `Last-Modified`.
## Implementation Notes
- Body storage uses the `Uint8Array` reference verbatim; callers that
need defensive copies must slice before inserting. The cache treats
bodies as immutable from its own perspective, but JavaScript cannot
enforce that — production code typically uploads the same buffer it
stored, so the lack of copy is fine in practice.
- `lastModified` is stored as the raw header string (not a parsed `Date`)
so the cache stays trivially serialisable — useful when a future
consumer adds a cross-process cache layer that persists entries
between processes via JSON or msgpack.
- The cache is keyed by sanitised storage key (`HttpStorage` calls
`sanitizeKey` before any cache access), so two callers requesting the
same logical key always share the same cache slot regardless of
whether they passed Windows-style or POSIX-style separators.
- Capacity is a hard upper bound, not an aspirational target — the LRU
evicts the oldest entry the moment a `set` would otherwise breach it.
Callers tuning for high cache-hit ratios should size generously.

View File

@@ -0,0 +1,74 @@
# fetch.types
## Purpose
Declares `FetchLike` — the structural shape `HttpStorage` and every helper
in this package programs against. By depending on `FetchLike` rather than
`globalThis.fetch` directly, the adapter stays trivially testable: tests
inject a controlled `FetchLike` instance and never have to spy on the
global.
## Features
- Single-type module — `FetchLike` only.
- Matches `globalThis.fetch`'s public surface (no provider-specific extras).
- Accepts `string`, `URL`, or `Request` inputs — all three valid `fetch`
argument shapes.
- Promise<Response> return matches the WHATWG fetch contract exactly.
## Usage: Basic
```ts
import type { FetchLike } from "@suaveplan/storage-http";
const fetchImpl: FetchLike = async (input, init) => {
return new Response(new Uint8Array([1, 2, 3]));
};
```
## Usage: Advanced
```ts
import type { FetchLike } from "@suaveplan/storage-http";
function withLogging(inner: FetchLike): FetchLike {
return async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
console.log("fetch", url);
const response = await inner(input, init);
console.log("status", response.status);
return response;
};
}
```
## API Reference
### `type FetchLike`
```ts
(input: string | URL | Request, init?: RequestInit) => Promise<Response>
```
Any function matching that shape is a valid `FetchLike`. The default impl
returned by `defaultFetch()` wraps `globalThis.fetch` and adds error
mapping.
## Implementation Notes
- The type intentionally accepts all three documented fetch input shapes
so consumers can pass `URL` or `Request` objects rather than always
stringifying first. Tests typically pass strings for clarity; production
code may pass `URL` instances to avoid re-parsing on each call.
- `init` is `RequestInit` — the same options bag as native `fetch`. Tests
inspect `init.signal` to verify AbortSignal threading from each public
method on `HttpStorage` all the way down to the underlying request.
- No DOM-only types leak into the public API — `RequestInit` is provided
by `@types/node` as part of Node 18+'s built-in fetch typings, so the
adapter typechecks identically under Node, Bun, Deno, browsers, and
Cloudflare Workers without lib augmentation.
- Returning a custom `FetchLike` is the canonical way to add cross-cutting
concerns (logging, retry-classification overrides, response caching,
client-side throttling) without monkey-patching the global `fetch`.
Compose multiple `FetchLike` wrappers freely — they are plain async
functions and stack cleanly.

View File

@@ -0,0 +1,91 @@
# http-storage.types
## Purpose
Declares the public option types accepted by `HttpStorage` and the supporting
helper types they reference. These declarations are the contract every
consumer programs against: the constructor parses them through
`HttpStorageOptionsSchema` at the boundary, and the running adapter trusts
the shape thereafter.
## Features
- `HttpStorageOptions` — full option bag covering `baseUrl`, `headers`,
`fetch`, `uploader`, `keyEncoder`, `retry`, `timers`, `etagCache`.
- `HttpStorageOp` — discriminator the `headers` callback receives.
- `HttpHeadersResolver` — static record or async per-op function.
- `KeyEncoder` — pluggable per-segment encoder for CDNs whose signed-URL
signatures need alternate encoding.
## Usage: Basic
```ts
import type { HttpStorageOptions } from "@suaveplan/storage-http";
const options: HttpStorageOptions = {
baseUrl: "https://cdn.example.com/start/here/",
};
```
## Usage: Advanced
```ts
import type {
HttpHeadersResolver,
HttpStorageOp,
HttpStorageOptions,
KeyEncoder,
} from "@suaveplan/storage-http";
const headers: HttpHeadersResolver = async (key, op: HttpStorageOp) => ({
Authorization: `Bearer ${await fetchToken(op)}`,
"x-tenant": tenantFor(key),
});
const encoder: KeyEncoder = (key) =>
key.split("/").map(encodeURIComponent).join("/");
const options: HttpStorageOptions = {
baseUrl: "https://cdn.example.com/start/here/",
headers,
keyEncoder: encoder,
retry: { maxAttempts: 5, baseDelayMs: 200, honourRetryAfter: true },
etagCache: { capacity: 1024 },
};
```
## API Reference
### `interface HttpStorageOptions`
- `baseUrl: string` — required absolute http(s) URL ending with `/`.
- `headers?: HttpHeadersResolver` — static record or per-op function.
- `fetch?: FetchLike` — injectable fetch; defaults to `defaultFetch()`.
- `uploader?: HttpUploader` — pluggable write strategy.
- `keyEncoder?: KeyEncoder` — defaults to per-segment `encodeURIComponent`.
- `retry?: HttpRetryOptions` — retry tuning.
- `timers?: TimersLike` — injectable timers for tests.
- `etagCache?: EtagCacheOptions` — opt-in conditional-GET cache.
### `type HttpStorageOp = "get" | "head" | "getPartial" | "set" | "delete"`
Discriminator for the `headers` callback so it can return op-specific values
(e.g. an upload `Content-Type` distinct from a read `If-None-Match`).
### `type HttpHeadersResolver`
Either `Record<string, string>` or
`(key: string, op: HttpStorageOp) => Record<string, string> | Promise<…>`.
### `type KeyEncoder = (key: string) => string`
## Implementation Notes
- All option fields except `baseUrl` are optional; constructing
`HttpStorage` with just `{ baseUrl }` produces a fully-functional
read-only adapter against a public origin.
- `HttpHeadersResolver` may return a Promise — useful when the caller's
token store is async (OAuth refresh, secrets manager fetch).
- `HttpStorageOp` deliberately omits opaque values like `clear` or `list`
because the adapter never invokes the `headers` callback for those —
they short-circuit to `READ_ONLY` before any fetch is composed.

View File

@@ -0,0 +1,72 @@
# retry.types
## Purpose
Declares the retry-tuning option shape consumed by both `retryWithBackoff`
and `HttpStorage`, plus a re-export of the cross-package `TimersLike`
contract from `@suaveplan/types`. Centralising these here keeps the
helper module and the public class consistent — both see the same numeric
knobs and the same injectable-clock surface.
## Features
- Single shape for retry tuning across the package.
- `honourRetryAfter` flag lets callers opt out of server-driven waits when
they don't trust upstream `Retry-After` headers.
- Re-exported `TimersLike` so consumers can import retry types and the
clock contract from the same module.
## Usage: Basic
```ts
import type { HttpRetryOptions } from "@suaveplan/storage-http";
const retry: HttpRetryOptions = { maxAttempts: 5 };
```
## Usage: Advanced
```ts
import type { HttpRetryOptions, TimersLike } from "@suaveplan/storage-http";
import { FakeClock } from "@suaveplan/testing";
const clock = new FakeClock();
const timers: TimersLike = {
setTimer: (handler, ms) => clock.setTimeout(handler, ms),
clearTimer: (id) => clock.clearTimeout(id as number),
};
const retry: HttpRetryOptions = {
maxAttempts: 4,
baseDelayMs: 100,
maxDelayMs: 5_000,
honourRetryAfter: true,
};
```
## API Reference
### `interface HttpRetryOptions`
- `maxAttempts?: number` — inclusive cap. Defaults to
`DEFAULT_RETRY_MAX_ATTEMPTS` (3).
- `baseDelayMs?: number` — initial exponential-backoff delay. Defaults to
`DEFAULT_RETRY_BASE_DELAY_MS` (100).
- `maxDelayMs?: number` — backoff cap. Defaults to
`DEFAULT_RETRY_MAX_DELAY_MS` (30000).
- `honourRetryAfter?: boolean` — defaults to `true`.
### Re-exported `TimersLike`
From `@suaveplan/types` — `{ setTimer, clearTimer }`. See that package's
docs for the canonical contract.
## Implementation Notes
- `honourRetryAfter = true` is the safe default — most well-behaved
origins use the header correctly. Set to `false` for origins known to
return abusive values that exceed reasonable wait windows.
- `RETRY_AFTER_MAX_DELAY_MS` (60s) caps the value the helper honours even
when `honourRetryAfter = true` — a single defensive ceiling so
malicious or misconfigured origins can't stall callers for hours.
- `TimersLike` is re-exported rather than re-declared so there is exactly
one canonical contract in the workspace.

View File

@@ -0,0 +1,82 @@
# uploader.types
## Purpose
Declares the pluggable write strategy interfaces consumed by `HttpStorage`.
`HttpUploader` is the seam that lets `HttpStorage` stay read-only by
default while still supporting writes against any HTTP origin a provider
package decides to support. `PresignedUrlSigner` is the callback shape
`presignedPutUploader` consumes — provider-agnostic glue between an
arbitrary URL-signing function and a working uploader.
## Features
- `HttpUploader.put` — required write operation.
- `HttpUploader.delete?` — optional; absence makes the adapter throw
`STORAGE_HTTP.READ_ONLY` on `delete`.
- `PresignedUrlSigner` — async function returning a signed URL plus
optional headers.
- `PresignedUrlInfo` — output shape of `PresignedUrlSigner`.
## Usage: Basic
```ts
import type { HttpUploader } from "@suaveplan/storage-http";
const uploader: HttpUploader = {
put: async (key, body) => {
await fetch(`https://upload.example.com/${key}`, {
method: "PUT",
body: body as Uint8Array,
});
},
};
```
## Usage: Advanced
```ts
import type {
HttpUploader,
PresignedUrlInfo,
PresignedUrlSigner,
} from "@suaveplan/storage-http";
const signer: PresignedUrlSigner = async (key) => {
const { url, expires } = await signMyProvider("PUT", key, 60);
return {
url,
headers: { "x-expires": String(expires) },
} satisfies PresignedUrlInfo;
};
const uploader: HttpUploader = presignedPutUploader({ signer });
```
## API Reference
### `interface HttpUploader`
- `put(key, body, headers?, signal?): Promise<void>` — required.
- `delete?(key, signal?): Promise<void>` — optional.
`body` accepts `Uint8Array` or `ReadableStream<Uint8Array>`.
### `type PresignedUrlSigner = (key: string) => Promise<PresignedUrlInfo>`
Async by design — many real signers require an upstream call (STS,
signed-cookie API).
### `interface PresignedUrlInfo`
`{ url: string; headers?: Record<string, string> }`.
## Implementation Notes
- `delete` is intentionally optional because most signed-URL schemes are
write-only; provider packages with native delete support populate it.
- `put` accepts `ReadableStream<Uint8Array>` for forward compatibility,
but the default `presignedPutUploader` currently treats body as bytes
— provider packages with native multipart streaming override this.
- Headers from the signer are merged with the adapter's per-request
headers (caller wins on conflict) — see `uploader.md`.

View File

@@ -0,0 +1,71 @@
# uploader
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the pluggable write strategy for `HttpStorage` and ships the only generic uploader implementation: `presignedPutUploader`. `HttpStorage` calls the injected `HttpUploader` on `set`/`delete`; without an uploader, the core is read-only and writes throw `STORAGE_HTTP.READ_ONLY`. Provider-specific packages (S3, R2, GCS, Azure Blob, Rackspace, BunnyCDN) supply their own signing logic via a `PresignedUrlSigner` callback, so this module stays zero-coupling.
## Features
- `HttpUploader` interface — `put(key, body, headers?, signal?): Promise<void>` plus optional `delete(key, signal?)`.
- `presignedPutUploader({ signer, fetch? })` — composes a signer into a working uploader without baking in any provider's signing scheme.
- Per-call signing: every `put` invocation resolves a fresh `(url, headers)` pair so short-lived signed URLs work naturally.
- Headers from the signer and the per-request headers from `HttpStorage` are merged (caller's `headers` win on conflict — they are the more specific override).
- Non-`ok` responses surface as `StorageHttpError(STORAGE_HTTP.UPSTREAM_ERROR)` with the URL and HTTP status carried for diagnostics.
## Usage: Basic
```ts
import { presignedPutUploader } from "@suaveplan/storage-http";
const uploader = presignedPutUploader({
signer: async (key) => ({
url: await signS3Url(key, { method: "PUT", ttlSeconds: 60 }),
}),
});
await uploader.put("a/b.bin", bytes);
```
## Usage: Advanced
```ts
import { HttpStorage, presignedPutUploader } from "@suaveplan/storage-http";
const storage = new HttpStorage({
baseUrl: "https://cdn.example.com/start/here/",
uploader: presignedPutUploader({
signer: async (key) => {
const { url, headers } = await myPresigner.presign("PUT", key, 60);
return { url, headers: { ...headers, "x-amz-meta-uploader": "suaveplan" } };
},
}),
});
await storage.set("a/b.bin", bytes);
```
## API Reference
### `interface HttpUploader`
- `put(key, body, headers?, signal?): Promise<void>` — required.
- `delete?(key, signal?): Promise<void>` — optional; when omitted, `HttpStorage.delete` throws `READ_ONLY`.
### `type PresignedUrlSigner = (key: string) => Promise<PresignedUrlInfo>`
`PresignedUrlInfo` = `{ url: string; headers?: Record<string, string> }`.
### `presignedPutUploader(options): HttpUploader`
- `options.signer: PresignedUrlSigner` — required.
- `options.fetch?: FetchLike` — defaults to `globalThis.fetch`.
- Returns an `HttpUploader` whose `put` calls `signer(key)`, merges headers, and issues a `PUT`. Errors mapped to `STORAGE_HTTP.UPSTREAM_ERROR` with `httpStatus` populated.
## Implementation Notes
- Header merging is `{ ...fromSigner, ...fromCaller }` — caller headers win because they typically encode per-request `Content-Type` or `Content-Disposition` that the signer can't know in advance.
- `presignedPutUploader` does **not** sign the body — it expects the signer's URL and headers to already encompass whatever signing the destination requires (S3 SigV4 in the URL, Azure SAS in query, etc.).
- A `delete` capability is **not** included by default. Provider-specific packages implement it; `presignedPutUploader` does not because most signed-URL schemes are write-only.
- Request body type is `BodyInit` (the standard `fetch` body shape) — `Uint8Array` and `ReadableStream<Uint8Array>` both work; everything else is a programmer error caught at the type level.