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

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-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:

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):

  • 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

{
  "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.