243 lines
13 KiB
Markdown
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`.
|