# 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:///` — 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` with `uri`, `scheme`, `packageSlug`, `relPath`, `rootKind` discriminants for diagnostics. - ADDED: `parse(uri)` enforcing `bundle:///`: 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 `/` 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=-` 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 `-uri-plugin-` 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:/` 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.