Files
2026-05-27 23:29:53 +01:00

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 a UriPlugin registerable with createStorageUriResolver. One factory call per consuming package; one plugin per slug.
  • ADDED: BundleUriPluginOptions interface + BundleUriPluginOptionsSchema Zod schema. Required packageSlug: string (must match /^@?[a-z0-9][a-z0-9._/-]{0,127}$/), rootUrl: URL | string (resolved to a URL with trailing-slash enforced). Optional fetch: FetchLike, logger: LoggerLike, clock: Clock, cache: { maxBytes: number }.
  • ADDED: BUNDLE_URI_CODES registry via defineCodes("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> with uri, scheme, packageSlug, relPath, rootKind discriminants for diagnostics.
  • ADDED: parse(uri) enforcing bundle://<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 from parseUri).
  • ADDED: build(key, meta?) inverse — re-serialises <packageSlug>/<key> back to a bundle:// URI for list() round-trips and the conformance harness.
  • ADDED: Universal-tier resolution strategy (decision matrix in design.md §3): every read is executed via the injected FetchLike against new URL(encodedRelPath, rootUrl). file:, https:, http:, data:, blob:, and bundler-virtual schemes all flow through fetch. The package ships NO fs.readFile adapter — fetch of file: is supported on Node ≥ 21 and Bun natively, and is the canonical universal-tier surface.
  • ADDED: StorageLike shim implementing get, exists, getMetadata, getPartial, createReadStream, and explicitly-throwing set/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 Content returned verbatim, 200 OK slow-pathed (full body sliced client-side) with a bundle_uri.range_fallback_total{root_kind} counter increment, 416 Range Not Satisfiable → RANGE_INVALID. Underlying transports without range support (data:, blob: from some browsers) increment range_fallback_total and slice.
  • ADDED: Streaming via createReadStream(key, signal) returning Response.body directly when present; falls back to a one-chunk stream wrapping the buffered bytes when body is 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: AbortSignal threaded into every fetch call. Pre-aborted signals short-circuit with BUNDLE_URI.ABORTED before any fetch. Mid-flight abort propagates the underlying AbortError on cause.
  • 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-bundle tracer with attributes bundle.package, bundle.rel_path (truncated to 256 chars), bundle.root_kind (file | http | data | blob | other). Query strings stripped from every attribute (matches storage-uri precedent).
  • ADDED: runUriPluginConformance pass — wired into bundle-uri-plugin.spec-conformance.test.ts with validUris, invalidUris, and buildRoundTrip fixtures derived from a temp-dir file:// root and a localhost HTTP fixture root.

Out of scope

  • list(prefix). Bundle roots have no uniform directory protocol (file: could readdir, https: cannot). list SHALL throw BUNDLE_URI.READ_ONLY (capability listing: false). Reason: a manifest-backed list would couple to a specific build tool's manifest format; the consumer can register a storage-file plugin 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-cache in 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 FetchLike that adds the signature header — same pattern as storage-http.
  • Writes of any kind. Bundles are immutable by nature; set/delete/clear/setPartial unconditionally throw READ_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:

  1. @suaveplan/audio-ir-room-plugin-club (sibling-session, in-flight) — ships a club-room impulse response (assets/club.wav) inside its tarball; resolves it via bundle://audio-ir-room-plugin-club/assets/club.wav.
  2. @suaveplan/audio-ir-room-plugin-pool (sibling-session, in-flight) — ships a swimming-pool impulse response (assets/pool.wav) on the same pattern.
  3. Future consumers explicitly anticipated: font-subset sample-font shipping, compress-image reference-image bundles, qrcode reference-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), required fetch default = 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_TRAVERSAL after decode, build() round-trips.
  • src/storage-shim/storage-shim.test.ts — get against real file:// temp dir, get against a real node:http localhost fixture server, exists HEAD path, getMetadata parses content-length / content-type / last-modified / etag, every write method throws READ_ONLY, list throws READ_ONLY.
  • src/range/range.test.ts — getPartial(offset, length) issues Range: bytes=offset-end header against a real HTTP fixture, 206 path returns slice, 200 fallback path slices client-side and increments range_fallback_total, 416 → RANGE_INVALID.
  • src/streaming/streaming.test.ts — createReadStream returns Response.body for HTTP root, returns a one-chunk stream wrapping the buffered bytes when body is null.
  • src/abort/abort.test.ts — pre-aborted signal → BUNDLE_URI.ABORTED without fetch call; mid-flight abort propagates the underlying AbortError on cause.
  • src/cache/cache.test.ts — LRU off by default (cache hits counter stays at 0); LRU on evicts oldest entry when maxBytes exceeded; cache hit returns the same bytes without re-fetching.
  • src/error-codes/error-codes.test.ts — every BUNDLE_URI.* code is reachable from a real call site; defineCodes registry exhaustion test.
  • src/otel/otel.test.ts — op_duration_ms, counters, span attributes against an in-memory MeterProvider + SpanExporter; query strings stripped from bundle.rel_path attribute.
  • src/conformance/conformance.test.ts — runUriPluginConformance(bundleUriPlugin(...), {...}) passes against both a file:// temp-dir root and an http://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-bundle returning the package itself).

Affected upstreams

  • @suaveplan/storage-uri — consumed as the UriPlugin contract source + parseUri helper + runUriPluginConformance harness. Pure consumer; no changes required to storage-uri itself. [x] no action needed (purely additive consumer).
  • @suaveplan/types — consumed for FetchLike, LoggerLike, Clock, StorageLike, StatResult (latter via storage-uri's StreamingStorageExtensions). Pure consumer; no changes required. [x] no action needed (purely additive consumer).
  • @suaveplan/error — defineCodes + SuaveplanError base. Pure consumer. [x] no action needed.