13 KiB
Add @suaveplan/storage-uri-plugin-bundle — bundle:// URI scheme for per-package shipped assets
Package: @suaveplan/storage-uri-plugin-bundle
Status: PROPOSED
Type: new-package (genesis-original)
- Design: ./design.md
- Spec delta: ./specs/storage-uri-plugin-bundle/spec.md
Why
Several genesis packages need to ship per-package binary assets — impulse responses, sample fonts,
sample images, room-acoustic IRs — alongside their npm tarball, and resolve them through the
existing @suaveplan/storage-uri ecosystem so that consumers compose them with drive,
storage-plugin-cache, OTel, AbortSignal, and structured-error machinery for free.
There is no scheme today for "an asset shipped inside a package on disk / inside a CDN-hosted npm
tarball / inside a bundler's flattened asset graph". file:// couples to Node, https:// couples
to a specific deployment, and bundler-virtual schemes (asset:, data:, blob:) vary per bundler
and don't survive packaging. The bundle:// scheme — bundle://<package-slug>/<relative-path> —
fills that gap with a single contract that works identically on Node, Bun, browsers, and bundlers
that flatten assets (Vite, esbuild, webpack).
Per-package registration is the key design choice: each consuming package calls
bundleUriPlugin({ packageSlug, rootUrl: new URL("./", import.meta.url) }) from its own register()
and the resolver dispatches by slug. That makes the package the source of truth for "what does my
slug resolve to", lets bundlers do their normal URL-rewrite on the import.meta.url literal, and
keeps the universal tier honest — no Node-only fs.readFile, no browser-only Response, just
fetch over the resolved URL (which handles file:, https:, data:, blob: uniformly on Node
≥ 21, Bun, and modern browsers).
What changes
- ADDED:
bundleUriPlugin(options)factory returning aUriPluginregisterable withcreateStorageUriResolver. One factory call per consuming package; one plugin per slug. - ADDED:
BundleUriPluginOptionsinterface +BundleUriPluginOptionsSchemaZod schema. RequiredpackageSlug: string(must match/^@?[a-z0-9][a-z0-9._/-]{0,127}$/),rootUrl: URL | string(resolved to aURLwith trailing-slash enforced). Optionalfetch: FetchLike,logger: LoggerLike,clock: Clock,cache: { maxBytes: number }. - ADDED:
BUNDLE_URI_CODESregistry viadefineCodes("BUNDLE_URI", [...])— 9 codes:PACKAGE_MISMATCH,PATH_TRAVERSAL,READ_ONLY,NOT_FOUND,INVALID_URI,FETCH_FAILED,RANGE_INVALID,RANGE_UNSUPPORTED,ABORTED. - ADDED:
BundleUriError extends SuaveplanError<BundleUriErrorCode>withuri,scheme,packageSlug,relPath,rootKinddiscriminants for diagnostics. - ADDED:
parse(uri)enforcingbundle://<package-slug>/<rel-path>: slug mismatch →PACKAGE_MISMATCH;..traversal (raw or post-decode) →PATH_TRAVERSAL; empty rel-path, malformed scheme, missing authority →INVALID_URI(forwarded fromparseUri). - ADDED:
build(key, meta?)inverse — re-serialises<packageSlug>/<key>back to abundle://URI forlist()round-trips and the conformance harness. - ADDED: Universal-tier resolution strategy (decision matrix in
design.md §3): every read is executed via the injectedFetchLikeagainstnew URL(encodedRelPath, rootUrl).file:,https:,http:,data:,blob:, and bundler-virtual schemes all flow throughfetch. The package ships NOfs.readFileadapter —fetchoffile:is supported on Node ≥ 21 and Bun natively, and is the canonical universal-tier surface. - ADDED:
StorageLikeshim implementingget,exists,getMetadata,getPartial,createReadStream, and explicitly-throwingset/setPartial/delete/clear/list(bundles are immutable;READ_ONLY). - ADDED: Range reads via HTTP
Range: bytes=<start>-<end>header on the fetch init;206 Partial Contentreturned verbatim,200 OKslow-pathed (full body sliced client-side) with abundle_uri.range_fallback_total{root_kind}counter increment,416 Range Not Satisfiable→RANGE_INVALID. Underlying transports without range support (data:,blob:from some browsers) incrementrange_fallback_totaland slice. - ADDED: Streaming via
createReadStream(key, signal)returningResponse.bodydirectly when present; falls back to a one-chunk stream wrapping the buffered bytes whenbodyis null. - ADDED: Optional in-memory LRU cache keyed by full URI. Off by default; opt-in via
options.cache = { maxBytes }. Eviction policy: oldest-first when total bytes >maxBytes. NO stale-while-revalidate — bundles are content-addressed by package version. - ADDED:
AbortSignalthreaded into every fetch call. Pre-aborted signals short-circuit withBUNDLE_URI.ABORTEDbefore any fetch. Mid-flight abort propagates the underlyingAbortErroroncause. - ADDED: OTel surface —
bundle_uri.op_duration_ms{op, root_kind, outcome}histogram;bundle_uri.reads_total{root_kind, outcome},bundle_uri.errors_total{root_kind, code},bundle_uri.range_fallback_total{root_kind},bundle_uri.cache_hits_total{root_kind},bundle_uri.cache_misses_total{root_kind}counters; per-op span on@suaveplan/storage-uri-plugin-bundletracer with attributesbundle.package,bundle.rel_path(truncated to 256 chars),bundle.root_kind(file|http|data|blob|other). Query strings stripped from every attribute (matchesstorage-uriprecedent). - ADDED:
runUriPluginConformancepass — wired intobundle-uri-plugin.spec-conformance.test.tswithvalidUris,invalidUris, andbuildRoundTripfixtures derived from a temp-dirfile://root and a localhost HTTP fixture root.
Out of scope
list(prefix). Bundle roots have no uniform directory protocol (file:couldreaddir,https:cannot).listSHALL throwBUNDLE_URI.READ_ONLY(capabilitylisting: false). Reason: a manifest-backed list would couple to a specific build tool's manifest format; the consumer can register astorage-fileplugin if directory listing is needed.- Persistent disk cache. The optional in-memory LRU is per-plugin-instance only. Cross-process
caches are the consumer's job (compose
storage-plugin-cachein front). - Server-side asset shipping helpers. Build-tool plugins that copy assets into
dist/are build-config, not runtime. Out of scope here; addressed by consuming packages' own build configs. - Auth / signed URLs. If a consumer ships assets behind a signed CDN, they inject a
FetchLikethat adds the signature header — same pattern asstorage-http. - Writes of any kind. Bundles are immutable by nature;
set/delete/clear/setPartialunconditionally throwREAD_ONLY. There is no write-uploader slot — adding one would invalidate the immutability contract that callers (and caches) rely on.
Source mapping
This is a genesis-original package; there is no upstream in ../suaveplan/. The design
follows the genesis UriPlugin contract (openspec/specs/storage-uri/spec.md) and the
drive-uri/plugins/{drive,drive-mesh,drive-template} three-plugin reference pattern (each owns
one scheme, ships a factory, passes runUriPluginConformance).
Requirements with no upstream source line
| Requirement | Justification |
|---|---|
bundle:// scheme name |
Short, descriptive, distinct from file:/asset:/pkg:/npm: (which all have established meanings); matches the established <scheme>-uri-plugin-<scheme> naming convention of the package. |
Per-package registration with import.meta.url |
Lets bundlers do their normal URL-rewrite at the consumer's call site; the plugin never needs to know about bundler internals. |
fetch-only resolution (no fs.readFile branch) |
Keeps the universal tier honest — no Node-only / Bun-only branches. fetch(file:URL) works on Node ≥ 21 and Bun natively. |
Per-package slug enforcement in parse() |
Catches mis-routed URIs at the boundary (bundle://other-pkg/x against this plugin → PACKAGE_MISMATCH) before any I/O. |
.. traversal rejected both raw and post-decode |
Prevents an attacker from escaping the rootUrl via %2E%2E/... after URL decoding. |
Bundles immutable — every write throws READ_ONLY |
Lets consumers and caches rely on the URI being content-addressed by package version; relaxing this would silently invalidate downstream caches. |
Range reads via HTTP Range header |
Universal across file:/http:/https: on Node ≥ 21 / Bun (fetch honours Range against file:); falls back transparently when origin returns 200. |
| In-memory LRU off by default | Bundle reads are typically rare-once / many-cached upstream by a storage-plugin-cache; baking a default LRU would mask that composition and burn memory on cold pages. |
BUNDLE_URI.ABORTED distinct from FETCH_FAILED |
Lets retry / circuit-breaker classifiers distinguish caller-initiated cancellation from transport failure. |
≥2-consumer justification
@suaveplan/types rule (AGENTS.md §0 item 6): a new shared abstraction must serve ≥2 packages.
The bundle:// plugin will be consumed by:
@suaveplan/audio-ir-room-plugin-club(sibling-session, in-flight) — ships a club-room impulse response (assets/club.wav) inside its tarball; resolves it viabundle://audio-ir-room-plugin-club/assets/club.wav.@suaveplan/audio-ir-room-plugin-pool(sibling-session, in-flight) — ships a swimming-pool impulse response (assets/pool.wav) on the same pattern.- Future consumers explicitly anticipated:
font-subsetsample-font shipping,compress-imagereference-image bundles,qrcodereference-fixture bundles,audio-sample-pack-plugin-*family. Every package that needs to address its own shipped assets through a URI-routed dispatcher gains the same plumbing for free.
The factory is per-package — slug-scoped — so multiple consumers register parallel plugin instances against the same resolver without conflict, each owning its own slug.
Verification approach
Every requirement in the spec delta carries a **Verified by:** line referencing concrete
it(...) titles in these files:
src/factory/factory.test.ts— option validation, slug regex, rootUrl normalisation (trailing-slash enforced), requiredfetchdefault =globalThis.fetch.src/parse/parse.test.ts— well-formed URIs accepted, scheme mismatch →INVALID_URI, empty rel-path →INVALID_URI, slug mismatch →PACKAGE_MISMATCH, raw..→PATH_TRAVERSAL, encoded%2E%2E→PATH_TRAVERSALafter decode,build()round-trips.src/storage-shim/storage-shim.test.ts—getagainst realfile://temp dir,getagainst a realnode:httplocalhost fixture server,existsHEAD path,getMetadataparsescontent-length/content-type/last-modified/etag, every write method throwsREAD_ONLY,listthrowsREAD_ONLY.src/range/range.test.ts—getPartial(offset, length)issuesRange: bytes=offset-endheader against a real HTTP fixture, 206 path returns slice, 200 fallback path slices client-side and incrementsrange_fallback_total, 416 →RANGE_INVALID.src/streaming/streaming.test.ts—createReadStreamreturnsResponse.bodyfor HTTP root, returns a one-chunk stream wrapping the buffered bytes whenbodyis null.src/abort/abort.test.ts— pre-aborted signal →BUNDLE_URI.ABORTEDwithout fetch call; mid-flight abort propagates the underlyingAbortErroroncause.src/cache/cache.test.ts— LRU off by default (cache hits counter stays at 0); LRU on evicts oldest entry whenmaxBytesexceeded; cache hit returns the same bytes without re-fetching.src/error-codes/error-codes.test.ts— everyBUNDLE_URI.*code is reachable from a real call site;defineCodesregistry exhaustion test.src/otel/otel.test.ts—op_duration_ms, counters, span attributes against an in-memoryMeterProvider+SpanExporter; query strings stripped frombundle.rel_pathattribute.src/conformance/conformance.test.ts—runUriPluginConformance(bundleUriPlugin(...), {...})passes against both afile://temp-dir root and anhttp://localhost:<port>/fixture root.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/storage-uri-plugin-bundle to enumerate. The package is
new — no dependents currently. The two named future consumers (audio-ir-room-plugin-club,
audio-ir-room-plugin-pool) are in flight in a parallel session and will wire this in as part
of their own change.
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/storage-uri-plugin-bundlereturning the package itself).
Affected upstreams
@suaveplan/storage-uri— consumed as theUriPlugincontract source +parseUrihelper +runUriPluginConformanceharness. Pure consumer; no changes required tostorage-uriitself. [x] no action needed (purely additive consumer).@suaveplan/types— consumed forFetchLike,LoggerLike,Clock,StorageLike,StatResult(latter viastorage-uri'sStreamingStorageExtensions). Pure consumer; no changes required. [x] no action needed (purely additive consumer).@suaveplan/error—defineCodes+SuaveplanErrorbase. Pure consumer. [x] no action needed.