Initial Commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user