191 lines
13 KiB
Markdown
191 lines
13 KiB
Markdown
# 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.
|