Files
suaveplan/openspec/archive/2026-05-17-add-storage-uri-plugin-bundle/design.md
2026-05-27 23:29:53 +01:00

243 lines
13 KiB
Markdown

# 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=<offset>-<offset+length-1>`. 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`.