# Design — @suaveplan/storage-uri-plugin-bundle Non-normative companion to `proposal.md` + `specs/storage-uri-plugin-bundle/spec.md`. Captures URI grammar, resolution strategy, traversal-prevention, range semantics, tier-purity rationale, error-code matrix, and file layout. ## 1. URI grammar (ABNF, RFC 5234) ```abnf bundle-uri = "bundle://" package-slug "/" rel-path [ "?" query ] [ "#" fragment ] package-slug = [ "@" scoped-prefix "/" ] slug-segment ; e.g. "audio-ir-room-plugin-club" or "@suaveplan/font-subset" scoped-prefix = 1*( ALPHA / DIGIT / "-" / "_" ) slug-segment = 1*( ALPHA / DIGIT / "-" / "_" / "." ) ; canonical npm-package-name subset; case-insensitive match per RFC 3986 §3.1 rel-path = path-segment *( "/" path-segment ) path-segment = 1*pchar ; pchar = RFC 3986 §3.3 (unreserved / pct-encoded / sub-delims / ":" / "@") ; explicitly EXCLUDES the bare two-byte sequence ".." anywhere ; (raw or pct-decoded — see §4 Traversal prevention) query = *( pchar / "/" / "?" ) ; opaque to this plugin; preserved on outbound URL only when caller embeds fragment = *( pchar / "/" / "?" ) ; never sent on the wire (RFC 3986) ``` Constraints layered on top of the grammar: - Total URI length ≤ 4096 chars (inherited from `parseUri`). - `package-slug` length ≤ 128 chars after the optional `@scope/` prefix. - `rel-path` length ≤ 1024 chars; per-segment length ≤ 255 chars (filesystem-portable). - No NUL bytes, no whitespace (inherited from `parseUri`). - Scheme matched **case-insensitively** per RFC 3986 §3.1; `BUNDLE://` is valid input but is lowercased before the plugin sees it. ## 2. Resolution decision matrix The plugin owns one decision: given a `rel-path`, what URL does `fetch` see? The matrix: | `rootUrl` scheme | Resolved request URL | Range support | Notes | |---|---|---|---| | `file://` | `new URL(encodedRelPath, rootUrl)` | yes on Node ≥ 21 / Bun; falls back on browsers | Browsers reject `fetch('file://…')` for security; bundlers rewrite the literal to `https:` or virtual on browser builds | | `https://` / `http://` | `new URL(encodedRelPath, rootUrl)` | yes (HTTP `Range` header) | Standard CDN/origin path | | `data:` | rootUrl itself (rel-path ignored at single-resource roots) | no — slice client-side | Rare; only when the consumer wraps a single base64 asset as a "bundle" | | `blob:` | rootUrl itself (single-resource) | no — slice client-side | Browser-only; same single-resource model as `data:` | | Bundler-virtual (`virtual:`, `~asset:`, `\0…`) | `new URL(encodedRelPath, rootUrl)` | depends on bundler runtime — usually no | Treated as "other"; counter is `bundle.root_kind="other"` | `root_kind` is an OTel attribute derived once at construction: ```ts function classifyRootKind(rootUrl: URL): "file" | "http" | "data" | "blob" | "other" { switch (rootUrl.protocol) { case "file:": return "file"; case "http:": case "https:": return "http"; case "data:": return "data"; case "blob:": return "blob"; default: return "other"; } } ``` ## 3. Why `fetch` everywhere (tier-purity argument) The package MUST be universal-tier: zero browser-only, zero Node-only deps. The candidate strategies were: | Strategy | Universal? | Verdict | |---|---|---| | Inject a `StorageLike` at the consumer's tier | yes | Pushes the universal/Node split onto every consumer — defeats the point of the plugin | | Conditional `fs.readFile` for `file:` + `fetch` for `http:` | NO — `fs` is Node-only | Rejected: forces tier-specific build | | `fetch` for every scheme | YES — `fetch` is universal | **Chosen.** Node ≥ 21 and Bun natively support `fetch('file://…')`. Browsers reject `file://` per security policy, but bundlers rewrite the `import.meta.url`-derived `rootUrl` to `https:` (Vite, esbuild) or a virtual scheme on browser builds — that case lands as `http`/`other` `root_kind`. | `globalThis.fetch` is the default; tests inject a controlled `FetchLike` (per `storage-http` precedent — `FetchLike` is canonical in `@suaveplan/types`). No `node:fs`, no `node:http`, no `bun:*`, no `window.*`. The tier-purity grep (`grep -rnE "from ['\"](node:|bun:|process|globalThis\.window)['\"]"`) MUST return empty. ## 4. Traversal-prevention algorithm Two attack surfaces — raw `..` and pct-encoded `%2E%2E`. Both rejected: ```ts function rejectTraversal(rawRelPath: string): void { // Pass 1 — raw inspection (pre-decode). if (RAW_TRAVERSAL_RE.test(rawRelPath)) { throw new BundleUriError(BUNDLE_URI_CODES.PATH_TRAVERSAL, "raw `..` segment", { … }); } // Pass 2 — post-decode inspection. let decoded: string; try { decoded = decodeURIComponent(rawRelPath); } catch (cause) { throw new BundleUriError(BUNDLE_URI_CODES.INVALID_URI, "malformed percent-encoding", { cause, … }); } if (RAW_TRAVERSAL_RE.test(decoded)) { throw new BundleUriError(BUNDLE_URI_CODES.PATH_TRAVERSAL, "encoded `..` after decode", { … }); } // Pass 3 — defensive URL-resolve check. resolved.pathname MUST start with rootUrl.pathname. const resolved = new URL(encodedRelPath, rootUrl); if (!resolved.pathname.startsWith(rootUrl.pathname)) { throw new BundleUriError(BUNDLE_URI_CODES.PATH_TRAVERSAL, "URL resolved outside rootUrl", { … }); } } const RAW_TRAVERSAL_RE = /(^|\/)\.\.(\/|$)/; ``` Pass 3 is the belt-and-braces — even if a hypothetical input slipped past the first two passes, the URL-resolution check anchors every resolved URL inside `rootUrl.pathname`. ## 5. Range-request behaviour per `root_kind` | `root_kind` | `getPartial(offset, length)` strategy | |---|---| | `http` | Issue `GET` with `Range: bytes=-`. Expect `206 Partial Content` → return body bytes; `200 OK` → slow-path slice client-side AND increment `bundle_uri.range_fallback_total{root_kind:"http"}`; `416` → `RANGE_INVALID`. | | `file` | Same `Range` header. Node ≥ 21 / Bun's `fetch(file:URL)` honours `Range` and returns `206`. If the runtime returns `200` (older Node, some Bun versions), fallback path applies. | | `data` / `blob` | Single-resource. Always fetch full body, slice client-side, increment `range_fallback_total{root_kind}`. | | `other` | Same as `data`/`blob` — full body + client-side slice + counter. | Offset / length validation (pre-fetch): - `offset` MUST be a non-negative integer; otherwise `RANGE_INVALID`. - `length` MUST be a positive integer; otherwise `RANGE_INVALID`. - Sum `offset + length` exceeding `Number.MAX_SAFE_INTEGER` → `RANGE_INVALID`. ## 6. Error-code matrix | Code | Discriminants populated | Raised from | |---|---|---| | `BUNDLE_URI.PACKAGE_MISMATCH` | `uri`, `scheme`, `packageSlug`, `actual` (the offending slug) | `parse()` when URI's authority ≠ `options.packageSlug` | | `BUNDLE_URI.PATH_TRAVERSAL` | `uri`, `scheme`, `packageSlug`, `relPath` | `parse()` when rel-path contains `..` raw or post-decode, OR pass 3 URL-resolve check fails | | `BUNDLE_URI.READ_ONLY` | `uri`, `scheme`, `packageSlug`, `op` (the rejected operation name) | `set` / `setPartial` / `delete` / `clear` / `list` on the shim | | `BUNDLE_URI.NOT_FOUND` | `uri`, `scheme`, `packageSlug`, `relPath` | fetch returns `404` / `410` | | `BUNDLE_URI.INVALID_URI` | `uri`, `scheme` (when known) | `parse()` for malformed grammar, malformed pct-encoding, empty rel-path, missing authority | | `BUNDLE_URI.FETCH_FAILED` | `uri`, `scheme`, `rootKind`, `httpStatus` (when applicable), `cause` | fetch reject (DNS / TLS / abort-without-signal), or non-2xx non-404 status | | `BUNDLE_URI.RANGE_INVALID` | `uri`, `scheme`, `relPath`, `offset`, `length` | `getPartial` with invalid offset/length, OR fetch returns `416` | | `BUNDLE_URI.RANGE_UNSUPPORTED` | `uri`, `scheme`, `rootKind` | reserved for cases where the runtime cannot represent the Range request at all (currently unreachable; documented as forward-defensive). NOTE: declared in registry but per §2c MUST be wired — wired by the `data:`/`blob:` adapter where `Range` headers are stripped before the underlying `fetch` and a counter is recorded. | | `BUNDLE_URI.ABORTED` | `uri`, `scheme`, `cause` (the `AbortError`) | pre-aborted signal OR mid-flight abort propagation | Every code listed above is reached from a real call site exercised by the test suite — see `error-codes.test.ts` and the per-feature test files. ## 7. File layout ``` packages/storage/storage-uri-plugin-bundle/ package.json # v0.0.0, sideEffects:false, subpath exports per feature README.md tsconfig.json, tsconfig.lib.json vite.config.ts, vitest.config.ts # via @suaveplan/testing/config tests/setup/polyfills.ts src/ index.ts # explicit named re-exports (no `export *`) error-codes/ error-codes.ts # defineCodes("BUNDLE_URI", [...]); BundleUriError error-codes.md error-codes.test.ts index.ts types/ plugin-options.types.ts # BundleUriPluginOptions interface plugin-options.types.md plugin-options.schemas.ts # Zod schema for BundleUriPluginOptions plugin-options.schemas.md index.ts factory/ factory.ts # bundleUriPlugin(options) entry point factory.md factory.test.ts index.ts parse/ parse.ts # parse() + build() + traversal-prevention parse.md parse.test.ts index.ts storage-shim/ storage-shim.ts # StorageLike adapter wrapping FetchLike storage-shim.md storage-shim.test.ts index.ts range/ range.ts # Range header + 206/200/416 disambiguation range.md range.test.ts index.ts streaming/ streaming.ts # createReadStream forwarding streaming.md streaming.test.ts index.ts abort/ abort.ts # AbortSignal helpers + pre-abort check abort.md abort.test.ts index.ts cache/ cache.ts # in-memory LRU keyed by URI cache.md cache.test.ts index.ts otel/ otel.ts # tracer + meter wiring + recordOp helper otel.md otel.test.ts index.ts conformance/ conformance.test.ts # runUriPluginConformance pass (file + http fixtures) __benchmarks__/ bundle-uri.bench.ts # mitata: parse, fetch round-trip, range slow-path ``` `index.ts` at package root re-exports `bundleUriPlugin`, `BundleUriPluginOptions`, `BundleUriPluginOptionsSchema`, `BUNDLE_URI_CODES`, `BundleUriError`, `BundleUriErrorCode` — and nothing else. The conformance test is the only file in `src/conformance/` (no `.ts` impl, no `.md` required per §4 exclusion of `.test.ts`). ## 8. OTel attribute redaction - `bundle.rel_path` truncated to 256 chars (sliced; ellipsis NOT appended — the attribute is a diagnostic key, not human-presented). - Query strings stripped from every attribute by stripping at the first `?` before the `decodeURIComponent` step. - `bundle.package` never redacted (slug is a public identifier). - `cause` chain not put on attributes — preserved on the `BundleUriError` instance only. ## 9. Subpath exports ```json { "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "./factory/factory": { "import": { "types": "./dist/factory/factory.d.ts", "default": "./dist/factory/factory.js" } }, "./parse/parse": { "import": { "types": "./dist/parse/parse.d.ts", "default": "./dist/parse/parse.js" } }, "./storage-shim/storage-shim": { "import": { "types": "./dist/storage-shim/storage-shim.d.ts", "default": "./dist/storage-shim/storage-shim.js" } }, "./range/range": { "import": { "types": "./dist/range/range.d.ts", "default": "./dist/range/range.js" } }, "./streaming/streaming": { "import": { "types": "./dist/streaming/streaming.d.ts", "default": "./dist/streaming/streaming.js" } }, "./abort/abort": { "import": { "types": "./dist/abort/abort.d.ts", "default": "./dist/abort/abort.js" } }, "./cache/cache": { "import": { "types": "./dist/cache/cache.d.ts", "default": "./dist/cache/cache.js" } }, "./error-codes/error-codes":{ "import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" } }, "./otel/otel": { "import": { "types": "./dist/otel/otel.d.ts", "default": "./dist/otel/otel.js" } } } } ``` Nine subpaths matching nine feature directories — verified by §13 step 8 in `AGENTS.md`.