13 KiB
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)
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-sluglength ≤ 128 chars after the optional@scope/prefix.rel-pathlength ≤ 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:
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:
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):
offsetMUST be a non-negative integer; otherwiseRANGE_INVALID.lengthMUST be a positive integer; otherwiseRANGE_INVALID.- Sum
offset + lengthexceedingNumber.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_pathtruncated 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 thedecodeURIComponentstep. bundle.packagenever redacted (slug is a public identifier).causechain not put on attributes — preserved on theBundleUriErrorinstance only.
9. Subpath exports
{
"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.