11 KiB
Add @suaveplan/storage-http — URL/CDN-backed AbstractStorage adapter
Package: @suaveplan/storage-http
Status: PROPOSED
Type: new-package (genesis-original)
- Design: ./design.md
- Spec delta: ./specs/storage-http/spec.md
Why
Genesis ships filesystem (storage-file), S3 (storage-s3), browser (storage-browser-{opfs,indexeddb,localstorage}), and in-memory (@suaveplan/storage) adapters, but no adapter for the most common read-side surface on the public internet: a base URL plus a key. Consumers that need to read static assets served from a CDN (https://cdn.example.com/start/here/ + a/b/test.doc) currently have to bypass the AbstractStorage contract and hand-roll fetch — losing OTel instrumentation, AbortSignal threading, structured error mapping, capability discovery, and the module-augmentation registry. The drive plugin (drive-template hydration), i18n HTTP backend, and any browser-tier consumer that pulls signed-URL assets all benefit from a real AbstractStorage over HTTPS.
The adapter is read-first with a pluggable write strategy. Plain CDNs (Cloudflare, Fastly, CloudFront read-paths, static origins) are read-only and the adapter SHALL declare that via capabilities and throw STORAGE_HTTP.READ_ONLY on writes. CDNs backed by an upload origin (S3 presigned PUT, R2 signed URLs, custom upload endpoints) plug in via an injectable HttpUploader interface; the adapter ships no baked-in uploader to avoid coupling to any specific provider.
What changes
- ADDED:
HttpStorageclass extendingAbstractStorage— full key-value read surface overfetch, with optional write via injectedHttpUploader. - ADDED:
createHttpStorage(options)factory. - ADDED:
HttpStorageOptionsinterface +HttpStorageOptionsSchemaZod schema. RequiredbaseUrl(must end with/); optionalheaders(static or per-request callback),fetch(injectableFetchLikefor testing),timeout,retry(delegates to@suaveplan/retry),uploader(HttpUploaderfor writes),keyEncoder(default:encodeURIComponentper path segment, preserving/),etagCache(in-memory weak cache for conditional requests). - ADDED:
FetchLikestructural interface — minimal(input, init?) => Promise<Response>shape;defaultFetch()adapter wrappingglobalThis.fetchwithAbortSignalplumbing. - ADDED:
HttpUploaderinjectable interface —{ put(key, body, headers?, signal?), delete?(key, signal?) }. Adapter MAY ship a thinpresignedPutUploader(presigner)helper but no provider-specific uploaders. - ADDED:
sanitizeKey(key)strict key boundary — rejects empty,..segments, absolute paths (/-prefixed), null bytes, control chars (0x00–0x1F,0x7F), keys over 2048 chars (KEY_TOO_LONG); normalises Windows\to POSIX/; URL-encodes each path segment via thekeyEncoderwhile preserving/. - ADDED:
STORAGE_HTTP_CODESregistry viadefineCodes("STORAGE_HTTP", [...])— 10 codes:NOT_FOUND,UNAUTHORIZED,FORBIDDEN,NETWORK_ERROR,INVALID_KEY,KEY_TOO_LONG,INVALID_OPTIONS,READ_ONLY,ABORTED,UPSTREAM_ERROR. - ADDED:
StorageHttpError extends SuaveplanError<StorageHttpErrorCode>withkey,httpStatus, andurldiscriminants for diagnostics. - ADDED: Module-augmentation registry —
declare module "@suaveplan/storage"addshttp: HttpStoragetoStorageProviderRegistryandhttp: HttpStorageOptionstoStorageProviderConfig;registerHttpProvider()registers the constructor with the factory. - ADDED: Range-read support via HTTP
Rangeheader —getPartial(offset, length)issuesRange: bytes=<offset>-<offset+length-1>; declaredcapabilities.partialReads: true. Servers that reply200instead of206are slow-pathed (full body sliced on the client) and counted understorage_http.range_fallback_total. - ADDED:
exists(key)viaHEADrequest;getMetadata(key)readsContent-Length,Content-Type,Last-Modified, andETagfrom aHEADresponse (or piggy-backed on the lastGETviaetagCache). - ADDED: Conditional reads — when
etagCacheis enabled,getsendsIf-None-Matchand treats304 Not Modifiedas a cache hit, returning the cached body without retransmission. - ADDED: Native Web Streams via
createReadStream(key, signal)returning theResponse.bodydirectly when present (declaredcapabilities.nativeStreams: true).createWriteStreamis unavailable on read-only configurations and throwsSTORAGE_HTTP.READ_ONLY; when anHttpUploaderis injected, the write stream collects to memory and callsuploader.putonclose(the underlying surface is request-bodied, not chunked). - ADDED:
@suaveplan/retryintegration on transient status codes (408,425,429,5xx) and network failures, withRetry-Afterhonoured when present and exponential backoff otherwise.4xxother than the transient set SHALL NOT retry. - ADDED: OTel surface —
storage_http.op_duration_ms{op,outcome,status_class}andstorage_http.io_bytes{op,direction}histograms;storage_http.{reads,writes,deletes,errors,range_fallback}_total{outcome}counters; per-op span on@suaveplan/storage-httptracer withhttp.url,http.method,http.status_code,http.response_content_length,storage.keyattributes.
Out of scope
- Provider-specific uploaders. S3 presigned PUTs, R2 signed URLs, Azure Blob SAS, GCS signed URLs — each ships in its own package (e.g.
storage-http-uploader-s3) or is composed by the consumer via theHttpUploaderslot. Reason: keeps the core adapter zero-coupling and dependency-light. - Listing (
list(prefix)). HTTP origins do not have a uniform directory protocol.listSHALL throwSTORAGE_HTTP.READ_ONLY(capabilitylisting: falsedeclared on the adapter). Consumers that need listing should usestorage-s3directly. Reason: any sensible implementation requires provider-specific knowledge (S3ListObjectsV2, WebDAVPROPFIND, etc.); shipping an opinionated default would mislead callers. - Persistent disk cache. The optional
etagCacheis an in-memory LRU; cross-process caches belong to the consumer (aNamespacedStorageoverstorage-fileis the recommended composition for that). Reason: cache invalidation policy is application-specific. - Authentication state machine. The
headerscallback runs on every request, so callers can plug in token rotation, but the adapter does not own the auth lifecycle. OAuth refresh, mTLS, AWS SigV4 — out of scope. Reason: orthogonal concern; standard composition pattern. setPartialrandom-access writes. HTTP does not exposepwritesemantics uniformly (S3multipart-upload, R2multipart, GCSresumableare all incompatible).setPartialSHALL fall through toAbstractStorage.defaultSetPartialonly when anHttpUploaderis wired AND the storage is read-write; otherwise it throwsSTORAGE_HTTP.READ_ONLY.capabilities.partialWrites: false.- WebDAV. A genuine
PROPFIND/COPY/MOVEadapter is a separate package.
Source mapping
This is a genesis-original package; there is no upstream in ../suaveplan/. All requirements derive from the genesis AbstractStorage contract (openspec/specs/storage/spec.md) and the storage-file/storage-s3 adapter precedents. The package contract follows the same shape as storage-file (atomic writes are inapplicable; the analogue is "request body is committed in one call").
Requirements with no source line
| Requirement | Justification |
|---|---|
Extend AbstractStorage |
Every storage adapter in genesis extends AbstractStorage for StorageProviderRegistry compatibility. |
AbortSignal on every public async method |
Required by AbstractStorage base signatures; threaded straight into fetch's init.signal. |
baseUrl must end with / |
Avoids the classic URL resolution footgun where new URL("a", "https://x.com/b") yields https://x.com/a instead of https://x.com/b/a. Documented; validated by Zod schema. |
keyEncoder injectable |
Some CDNs require unencoded + or specific casing for signed-URL signatures; per-segment encoding is the sane default but must be overridable. |
HttpUploader slot rather than baked-in writes |
HTTP write APIs are not standardised; coupling the adapter to one provider would force every consumer to depend on that provider. The slot keeps the core read-only-by-default while permitting any write strategy. |
READ_ONLY capability + error code |
Lets consumers test write capability without trying-and-catching, and surfaces the failure with a stable error code when they don't. |
| Range fallback metric | Quietly accepting 200 for a Range request is a correctness hazard for large objects; emitting a counter lets operators alert on misconfigured origins. |
etagCache is in-memory only |
Cross-process caching is the consumer's job (compose with storage-file). |
STORAGE_HTTP.UPSTREAM_ERROR distinct from NETWORK_ERROR |
5xx is a real server response; NETWORK_ERROR is fetch reject (DNS, TLS, abort-without-signal). Distinguishing them helps retry classifiers and operators. |
Verification approach
Every requirement in the spec delta carries a **Verified by:** line referencing concrete it(...) titles in these files:
src/http-storage/http-storage.test.ts— CRUD via injectedFetchLike, range reads, conditional reads with ETag cache, AbortSignal threading, retry on 429/503 +Retry-After,READ_ONLYenforcement when no uploader, write path with injected uploader, OTel happy paths.src/http-storage/http-storage.spec-conformance.test.ts— type-level conformance againstAbstractStorage, capabilities matrix, OTel instruments observed via real in-memoryMeterProvider, idempotent registry registration, error-code registry exhaustion.src/key-sanitizer/key-sanitizer.test.ts— empty, traversal, absolute, null bytes, control chars, overlong (>2048), separator normalisation, default per-segment encoding (preserves/, encodes?,#, space).src/fetch-adapter/fetch-adapter.test.ts—defaultFetch()forwards toglobalThis.fetch, propagatesAbortSignal, maps network rejection toSTORAGE_HTTP.NETWORK_ERROR.src/uploader/uploader.test.ts—presignedPutUploaderhelper round-trips a presigned URL signer; injected uploader is invoked exactly once onset.src/range/range.test.ts—Rangeheader format,206happy path,200fallback path incrementsrange_fallback_total,416 Range Not Satisfiablemaps toNOT_FOUNDwhen length unknown.src/etag-cache/etag-cache.test.ts— LRU eviction,304 Not Modifiedreturns cached body without retransmission, cache miss falls through.src/otel/otel.test.ts—recordOpemits histogram + counter withstatus_classattribute against a realInMemoryMetricExporter.src/registry/registry.test.ts— registershttpprovider,createStorage({ type: "http", config })returnsHttpStorage, idempotent re-registration.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/storage-http to enumerate. The package is new — no dependents currently. No action needed.
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/storage-httpreturning the package itself).
Affected upstreams
@suaveplan/storage—StorageProviderRegistrymodule-augmented to addhttp: HttpStorageslot. Additive only; no breaking change. [x] no action needed (purely additive type slot).