Files
2026-05-27 23:29:53 +01:00

33 KiB

Capability Spec: storage-uri-plugin-bundle

Package: @suaveplan/storage-uri-plugin-bundle Status: SHIPPED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

Purpose

@suaveplan/storage-uri-plugin-bundle is a per-package bundle:// URI scheme plugin for @suaveplan/storage-uri. It lets a consuming package ship binary assets alongside its tarball and resolve them through the same StorageUriResolver that handles file:, https:, s3:, and drive: URIs. The plugin is universal-tier: every read goes through an injectable FetchLike against new URL(encodedRelPath, rootUrl), so file:, http(s):, data:, blob:, and bundler-virtual schemes all flow through the same code path. Bundles are immutable by contract — every write surface (set, setPartial, delete, clear, list) throws BUNDLE_URI.READ_ONLY so consumers and caches can rely on the URI being content-addressed by package version.

Requirements

Requirement: Plugin factory shape and registration

The package SHALL export bundleUriPlugin(options: BundleUriPluginOptions): UriPlugin returning a UriPlugin (per @suaveplan/storage-uri) with scheme === "bundle". The factory SHALL validate options through BundleUriPluginOptionsSchema (Zod) at construction; validation failures SHALL throw BundleUriError with code === "BUNDLE_URI.INVALID_URI" AND a cause carrying the Zod issue. options.rootUrl SHALL be normalised to a URL instance with a trailing-slash pathname; inputs already ending in / SHALL be accepted verbatim, inputs without / SHALL be rejected with INVALID_URI rather than silently amended (so caller mistakes surface). The factory SHALL default options.fetch to globalThis.fetch.bind(globalThis) when unset.

Scenario: factory returns a UriPlugin with scheme "bundle"

  • WHEN a caller invokes bundleUriPlugin({ packageSlug: "audio-ir-room-plugin-club", rootUrl: new URL("file:///pkgs/club/assets/") })
  • THEN the returned object has scheme === "bundle", a callable parse, a callable build, and an adapter that is structurally a StorageLike

Scenario: invalid slug rejected at construction

  • WHEN a caller invokes bundleUriPlugin({ packageSlug: "Invalid Slug With Spaces", rootUrl: new URL("file:///x/") })
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.INVALID_URI"

Scenario: rootUrl missing trailing slash rejected

  • WHEN a caller invokes bundleUriPlugin({ packageSlug: "x", rootUrl: "file:///pkgs/x/assets" })
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.INVALID_URI" and message naming the trailing-slash requirement

Verified by: src/factory/factory.test.ts::"returns a UriPlugin with scheme bundle" AND src/factory/factory.test.ts::"rejects invalid slug at construction" AND src/factory/factory.test.ts::"rejects rootUrl missing trailing slash"


Requirement: bundle:// URI grammar enforced by parse

The plugin's parse(uri) SHALL accept any URI of the form bundle://<package-slug>/<rel-path>[?query][#fragment] where <package-slug> matches the authority component and <rel-path> is the non-empty URL-decoded path within rootUrl. The scheme SHALL be matched case-insensitively per RFC 3986 §3.1. parse SHALL return { key: relPath, meta: { packageSlug } } per the ParsedUri contract. Malformed URIs (missing authority, empty rel-path, scheme other than bundle, malformed pct-encoding) SHALL throw BundleUriError with code === "BUNDLE_URI.INVALID_URI".

Scenario: well-formed URI parses to key plus meta

  • WHEN parse("bundle://audio-ir-room-plugin-club/assets/club.wav") is called
  • THEN the result is { key: "assets/club.wav", meta: { packageSlug: "audio-ir-room-plugin-club" } }

Scenario: scheme mismatch rejected

  • WHEN parse("https://audio-ir-room-plugin-club/assets/club.wav") is called
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.INVALID_URI"

Scenario: empty rel-path rejected

  • WHEN parse("bundle://audio-ir-room-plugin-club/") or parse("bundle://audio-ir-room-plugin-club") is called
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.INVALID_URI"

Scenario: missing authority rejected

  • WHEN parse("bundle:///assets/club.wav") is called
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.INVALID_URI"

Verified by: src/parse/parse.test.ts::"parses well-formed URI to key plus meta" AND src/parse/parse.test.ts::"rejects scheme mismatch with INVALID_URI" AND src/parse/parse.test.ts::"rejects empty rel-path with INVALID_URI" AND src/parse/parse.test.ts::"rejects missing authority with INVALID_URI"


Requirement: Per-package slug enforcement

When the parsed authority differs from options.packageSlug, parse SHALL throw BundleUriError with code === "BUNDLE_URI.PACKAGE_MISMATCH" carrying both the expected slug (on packageSlug) and the offending slug (on actual). Slug comparison SHALL be case-sensitive (npm slug rules forbid uppercase, so a case difference is a real mismatch).

Scenario: URI slug not equal to options.packageSlug

  • WHEN a plugin configured with packageSlug: "audio-ir-room-plugin-club" is asked to parse("bundle://other-pkg/x")
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.PACKAGE_MISMATCH", packageSlug === "audio-ir-room-plugin-club", AND actual === "other-pkg"

Verified by: src/parse/parse.test.ts::"rejects slug mismatch with PACKAGE_MISMATCH carrying actual"


Requirement: Path-traversal prevention

parse SHALL reject any rel-path containing a .. segment, both in the raw input and after a single decodeURIComponent pass, throwing BundleUriError with code === "BUNDLE_URI.PATH_TRAVERSAL". As a defence-in-depth check, the resolved URL (new URL(encodedRelPath, rootUrl)) SHALL have a pathname that starts with rootUrl.pathname; any resolved URL that escapes rootUrl SHALL also throw PATH_TRAVERSAL.

Scenario: raw .. rejected

  • WHEN parse("bundle://x/../etc/passwd") is called against a plugin with packageSlug: "x"
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.PATH_TRAVERSAL"

Scenario: pct-encoded .. rejected after decode

  • WHEN parse("bundle://x/%2E%2E/etc/passwd") is called against a plugin with packageSlug: "x"
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.PATH_TRAVERSAL"

Scenario: URL-resolve escape check

  • WHEN a hypothetical input slips the first two passes but resolves to a pathname outside rootUrl.pathname
  • THEN the call throws BundleUriError with code === "BUNDLE_URI.PATH_TRAVERSAL"

Verified by: src/parse/parse.test.ts::"rejects raw .. segment with PATH_TRAVERSAL" AND src/parse/parse.test.ts::"rejects pct-encoded .. after decode with PATH_TRAVERSAL" AND src/parse/parse.test.ts::"rejects URL-resolve escape outside rootUrl with PATH_TRAVERSAL"


Requirement: Read-only enforcement on every write surface

The plugin's adapter (a StorageLike) SHALL throw BundleUriError with code === "BUNDLE_URI.READ_ONLY" for every method that would mutate state: set, setPartial, delete, clear, and list. Each rejection SHALL carry the offending operation name on a discriminant op so operators can correlate dashboards. No write surface SHALL be conditionally enabled — the contract is immutable bundles.

Scenario: set throws READ_ONLY carrying op

  • WHEN adapter.set("assets/x.wav", new Uint8Array([1,2,3])) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "set"

Scenario: setPartial throws READ_ONLY

  • WHEN adapter.setPartial("assets/x.wav", 0, new Uint8Array([1,2,3])) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "setPartial"

Scenario: delete throws READ_ONLY

  • WHEN adapter.delete("assets/x.wav") is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "delete"

Scenario: clear throws READ_ONLY

  • WHEN adapter.clear() is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "clear"

Scenario: list throws READ_ONLY

  • WHEN adapter.list({ prefix: "assets/" }) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.READ_ONLY" AND op === "list"

Verified by: src/storage-shim/storage-shim.test.ts::"set throws READ_ONLY with op set" AND src/storage-shim/storage-shim.test.ts::"setPartial throws READ_ONLY" AND src/storage-shim/storage-shim.test.ts::"delete throws READ_ONLY" AND src/storage-shim/storage-shim.test.ts::"clear throws READ_ONLY" AND src/storage-shim/storage-shim.test.ts::"list throws READ_ONLY"


Requirement: GET resolves through injected FetchLike

adapter.get(key, signal?) SHALL issue an HTTP GET to new URL(encodedRelPath, rootUrl) through the injected FetchLike and SHALL return the response body as Uint8Array. On 200 OK the body SHALL be returned. On 404 Not Found or 410 Gone the method SHALL throw BundleUriError with code === "BUNDLE_URI.NOT_FOUND". On any other non-success status the method SHALL throw BundleUriError with code === "BUNDLE_URI.FETCH_FAILED" carrying httpStatus and the upstream Response summary on cause.

Scenario: HTTP root returns bytes verbatim

  • WHEN the injected FetchLike resolves to new Response(new Uint8Array([1,2,3]), { status: 200 }) for http://localhost:<port>/club.wav
  • AND the caller invokes await adapter.get("club.wav")
  • THEN the result is a Uint8Array byte-equal to [1,2,3]

Scenario: file:// root returns bytes verbatim

  • WHEN a real temp-dir file:// rootUrl is configured AND club.wav exists at <rootUrl>/club.wav with bytes [9,8,7]
  • AND the caller invokes await adapter.get("club.wav") with a FetchLike capable of reading file: URLs (the runtime's native fetch on Bun, OR the nodeFileFetch adapter shipped at the ./node-fetch subpath on Node)
  • THEN the result is a Uint8Array byte-equal to [9,8,7]

NOTE (corrected post-ship 2026-05-17): Node's undici-backed global fetch does NOT support file: URLs in any release through Node 24 (the original design.md §3 claim of "Node ≥ 21 native support" was incorrect). Node consumers with a file:// rootUrl MUST pass the nodeFileFetch adapter from @suaveplan/storage-uri-plugin-bundle/node-fetch as options.fetch. Bun's native fetch supports file: directly so the default wiring works there.

Scenario: 404 → NOT_FOUND

  • WHEN the injected FetchLike resolves to new Response(null, { status: 404 })
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.NOT_FOUND"

Scenario: 500 → FETCH_FAILED with httpStatus

  • WHEN the injected FetchLike resolves to new Response(null, { status: 500 })
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.FETCH_FAILED" AND httpStatus === 500

Verified by: src/storage-shim/storage-shim.test.ts::"HTTP root returns bytes verbatim" AND src/storage-shim/storage-shim.test.ts::"file:// root returns bytes verbatim against real temp dir" AND src/storage-shim/storage-shim.test.ts::"404 maps to NOT_FOUND" AND src/storage-shim/storage-shim.test.ts::"500 maps to FETCH_FAILED with httpStatus"


Requirement: HEAD-backed exists and getMetadata

adapter.exists(key, signal?) SHALL issue HEAD <URL> and return true on 2xx, false on 404/410, and throw FETCH_FAILED on other non-success status. adapter.getMetadata(key, signal?) SHALL issue HEAD <URL> and parse content-length, content-type, last-modified, and etag from the response headers into the StatResult shape declared by @suaveplan/storage-uri. Absence of content-length SHALL yield size: undefined.

Scenario: HEAD 200 yields exists=true

  • WHEN the injected FetchLike resolves a HEAD to new Response(null, { status: 200 })
  • THEN await adapter.exists("k") is true

Scenario: HEAD 404 yields exists=false

  • WHEN the injected FetchLike resolves a HEAD to new Response(null, { status: 404 })
  • THEN await adapter.exists("k") is false

Scenario: getMetadata parses headers

  • WHEN the injected FetchLike resolves a HEAD to new Response(null, { status: 200, headers: { "content-length": "42", "content-type": "audio/wav", "etag": "\"abc\"", "last-modified": "Wed, 17 May 2026 00:00:00 GMT" } })
  • THEN await adapter.getMetadata("k") returns { size: 42, contentType: "audio/wav", etag: '"abc"', lastModified: Date(2026-05-17T00:00:00Z) }

Verified by: src/storage-shim/storage-shim.test.ts::"HEAD 200 yields exists true" AND src/storage-shim/storage-shim.test.ts::"HEAD 404 yields exists false" AND src/storage-shim/storage-shim.test.ts::"getMetadata parses content-length content-type etag last-modified"


Requirement: Range reads with fallback

adapter.getPartial(key, offset, length, signal?) SHALL validate offset >= 0 and length > 0 (both integers) and throw BundleUriError with code === "BUNDLE_URI.RANGE_INVALID" for any violation, without issuing a fetch. Valid ranges SHALL be requested via Range: bytes=<offset>-<offset+length-1>. A 206 Partial Content response SHALL return its body verbatim. A 200 OK response SHALL be slow-pathed (full body sliced client-side at [offset, offset+length)) AND SHALL increment bundle_uri.range_fallback_total{root_kind}. A 416 Range Not Satisfiable response SHALL throw BundleUriError with code === "BUNDLE_URI.RANGE_INVALID". For data: and blob: roots, every range read SHALL be slow-pathed (Range header dropped or unsupported) AND SHALL increment range_fallback_total.

Scenario: HTTP root 206 returns the slice

  • WHEN the injected FetchLike honours Range: bytes=10-19 with new Response(<10 bytes>, { status: 206 })
  • THEN await adapter.getPartial("k", 10, 10) returns the 10-byte body verbatim

Scenario: HTTP root 200 fallback increments counter

  • WHEN the injected FetchLike ignores the Range header and returns new Response(<100 bytes>, { status: 200 })
  • AND the caller invokes adapter.getPartial("k", 10, 10)
  • THEN the returned bytes equal body.slice(10, 20) AND bundle_uri.range_fallback_total{root_kind:"http"} is incremented by 1

Scenario: 416 maps to RANGE_INVALID

  • WHEN the injected FetchLike resolves to new Response(null, { status: 416 })
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.RANGE_INVALID"

Scenario: invalid offset rejected before fetch

  • WHEN adapter.getPartial("k", -1, 10) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.RANGE_INVALID" AND the injected FetchLike is NEVER invoked

Scenario: data: root always slow-pathed

  • WHEN the rootUrl is a data: URL AND adapter.getPartial("ignored", 0, 4) is called
  • THEN the bytes are sliced client-side AND bundle_uri.range_fallback_total{root_kind:"data"} is incremented by 1

Verified by: src/range/range.test.ts::"HTTP root 206 returns slice verbatim" AND src/range/range.test.ts::"HTTP root 200 fallback slices client-side and increments counter" AND src/range/range.test.ts::"416 maps to RANGE_INVALID" AND src/range/range.test.ts::"invalid offset rejected before fetch" AND src/range/range.test.ts::"data root always slow-pathed and counter incremented"


Requirement: Streaming reads

adapter.createReadStream(key, signal?) SHALL return a ReadableStream<Uint8Array>. When the underlying Response.body is non-null, the method SHALL return that stream directly without buffering. When Response.body is null (some adapter or polyfill paths), the method SHALL fall back to a one-chunk ReadableStream wrapping the buffered bytes.

Scenario: createReadStream forwards Response.body

  • WHEN the injected FetchLike returns a Response with a non-null body
  • THEN the returned stream is the same reference as response.body (no re-wrap)

Scenario: one-chunk fallback when body is null

  • WHEN the injected FetchLike returns a Response whose body is null AND whose arrayBuffer() resolves to Uint8Array([1,2,3])
  • THEN the returned stream yields exactly one chunk equal to Uint8Array([1,2,3]) then closes

Verified by: src/streaming/streaming.test.ts::"forwards Response.body when present" AND src/streaming/streaming.test.ts::"one-chunk fallback when Response.body is null"


Requirement: AbortSignal propagation

Every read on the adapter (get, exists, getMetadata, getPartial, createReadStream) SHALL accept an AbortSignal and SHALL thread it into the underlying fetch call. When the signal is already aborted at call time, the method SHALL reject with BundleUriError whose code === "BUNDLE_URI.ABORTED" WITHOUT invoking the injected FetchLike. Mid-flight aborts that propagate from fetch as an AbortError SHALL be rewrapped as BundleUriError(code: "BUNDLE_URI.ABORTED") carrying the original AbortError on cause.

Scenario: pre-aborted signal short-circuits

  • WHEN adapter.get("k", AbortSignal.abort()) is called
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.ABORTED" AND the injected FetchLike is NEVER invoked

Scenario: mid-flight abort propagates on cause

  • WHEN adapter.get("k", controller.signal) is called AND controller.abort() fires before the response resolves AND fetch rejects with an AbortError
  • THEN the call rejects with BundleUriError whose code === "BUNDLE_URI.ABORTED" AND whose cause is the original AbortError

Verified by: src/abort/abort.test.ts::"pre-aborted signal short-circuits without fetch" AND src/abort/abort.test.ts::"mid-flight abort propagates AbortError on cause"


Requirement: Optional in-memory LRU cache

The plugin SHALL support an optional in-memory LRU cache keyed by full URI. The cache SHALL be OFF by default; when options.cache = { maxBytes } is supplied, every successful get result SHALL be inserted into the cache AND subsequent gets of the same URI SHALL return the cached bytes WITHOUT invoking the injected FetchLike. The cache SHALL evict oldest entries first until total cached bytes ≤ maxBytes. Cache hits SHALL increment bundle_uri.cache_hits_total{root_kind}; misses SHALL increment bundle_uri.cache_misses_total{root_kind}.

Scenario: cache off by default

  • WHEN the plugin is constructed without options.cache AND the caller invokes get("k") twice
  • THEN the injected FetchLike is invoked twice AND bundle_uri.cache_hits_total stays at 0

Scenario: cache hit returns cached bytes without fetch

  • WHEN the plugin is constructed with options.cache = { maxBytes: 1024 } AND the caller invokes get("k") twice
  • THEN the injected FetchLike is invoked exactly once AND bundle_uri.cache_hits_total{root_kind} is incremented by 1

Scenario: LRU evicts oldest when maxBytes exceeded

  • WHEN the plugin is constructed with options.cache = { maxBytes: 100 } AND three get calls insert entries of size 50, 50, 50 in order
  • THEN after the third insert, the first entry (50 bytes) is evicted AND a subsequent get for its URI misses the cache

Verified by: src/cache/cache.test.ts::"cache off by default does not hit" AND src/cache/cache.test.ts::"cache hit returns cached bytes without fetch" AND src/cache/cache.test.ts::"LRU evicts oldest when maxBytes exceeded"


Requirement: OpenTelemetry instrumentation

Every public operation on the adapter SHALL emit a span on the @suaveplan/storage-uri-plugin-bundle tracer AND SHALL record bundle_uri.op_duration_ms{op, root_kind, outcome} on the meter. Errors SHALL increment bundle_uri.errors_total{root_kind, code} with the resolved BUNDLE_URI.* code. Spans SHALL carry bundle.package, bundle.rel_path (truncated to 256 chars; query strings stripped), and bundle.root_kind (file | http | data | blob | other) attributes. Cache and range fallback paths SHALL increment the dedicated counters declared in design.md §6.

Scenario: successful read records duration histogram

  • WHEN get("k") succeeds against an HTTP root
  • THEN the meter records one observation on bundle_uri.op_duration_ms{op:"get", root_kind:"http", outcome:"success"} AND one increment of bundle_uri.reads_total{root_kind:"http", outcome:"success"}

Scenario: error increments errors_total with resolved code

  • WHEN get("k") rejects with NOT_FOUND
  • THEN bundle_uri.errors_total{root_kind:"http", code:"BUNDLE_URI.NOT_FOUND"} is incremented by 1

Scenario: query string stripped from rel_path attribute

  • WHEN get("k?token=secret") is attempted (the caller-side rel-path includes a query)
  • THEN the emitted span's bundle.rel_path attribute is "k" (no ?token=…)

Scenario: root_kind classified correctly per resolution matrix

  • WHEN the plugin is constructed with rootUrl: new URL("file:///pkgs/x/") AND a successful get records a span
  • THEN the span's bundle.root_kind attribute is "file"

Verified by: src/otel/otel.test.ts::"records op_duration_ms on success" AND src/otel/otel.test.ts::"increments errors_total with resolved code" AND src/otel/otel.test.ts::"strips query string from rel_path attribute" AND src/otel/otel.test.ts::"classifies root_kind per resolution matrix"


Requirement: build() inverse for round-trip

The plugin SHALL expose a build(key, meta?) function that re-serialises a key plus optional meta into a bundle:// URI such that parse(build(key, { packageSlug })) === { key, meta: { packageSlug } } for every key the plugin's parse accepts. When meta?.packageSlug is absent, build SHALL default to options.packageSlug.

Scenario: build round-trips through parse

  • WHEN a plugin configured with packageSlug: "x" calls build("assets/y.wav")
  • THEN the result is "bundle://x/assets/y.wav" AND parse("bundle://x/assets/y.wav") returns { key: "assets/y.wav", meta: { packageSlug: "x" } }

Verified by: src/parse/parse.test.ts::"build round-trips through parse"


Requirement: Conformance harness pass

The package SHALL include a conformance test that invokes runUriPluginConformance(bundleUriPlugin(options), fixtures) from @suaveplan/storage-uri and SHALL pass without throwing against TWO real backing roots: (a) a temp-dir file:// root populated with fixture bytes; (b) a http://localhost:<port>/ root served by a real node:http fixture server stood up in beforeAll and torn down in afterAll. Both runs SHALL use the runtime's native fetch (no injected FetchLike) to prove the universal-tier resolution strategy works end-to-end.

Scenario: conformance passes against file:// root

  • WHEN the harness is invoked with validUris: ["bundle://x/a.bin"], invalidUris: ["bad"], buildRoundTrip: [{ key: "a.bin", uri: "bundle://x/a.bin" }] against a file:// rootUrl pointing at a temp dir containing a.bin
  • THEN all assertions pass

Scenario: conformance passes against http:// root

  • WHEN the harness is invoked with the same fixture shape against an http://localhost:<port>/ rootUrl served by a real node:http fixture server
  • THEN all assertions pass

Verified by: src/conformance/conformance.test.ts::"runUriPluginConformance passes against file:// root" AND src/conformance/conformance.test.ts::"runUriPluginConformance passes against http:// localhost fixture"


Requirement: Error-code registry exhaustion

The BUNDLE_URI_CODES registry exposed via defineCodes("BUNDLE_URI", [...]) SHALL contain exactly the nine codes declared in design.md §6: PACKAGE_MISMATCH, PATH_TRAVERSAL, READ_ONLY, NOT_FOUND, INVALID_URI, FETCH_FAILED, RANGE_INVALID, RANGE_UNSUPPORTED, ABORTED. Every code SHALL be reachable from a real call site exercised by the test suite — unused codes are banned per AGENTS.md §2c.

Scenario: every code is reachable

  • WHEN the test suite enumerates Object.values(BUNDLE_URI_CODES) AND maps each to a fixture call site
  • THEN every entry maps to at least one it(...) assertion that exercises the code from a real call site

Verified by: src/error-codes/error-codes.test.ts::"every BUNDLE_URI code is reachable from a real call site"


Requirement: Server-tier ./node-fetch subpath for file:// roots on Node

The package SHALL expose a dedicated server-tier subpath export ./node-fetch resolving to a FetchLike-compatible function nodeFileFetch that closes the real-world gap left by Node's undici-backed globalThis.fetch (which does not support file: URLs in any release through Node 24). The default package barrel (./) SHALL remain universal-tier — no node:* import SHALL be reachable from src/index.ts. nodeFileFetch SHALL:

  1. Inspect the input URL's protocol; non-file: URLs SHALL delegate to globalThis.fetch(input, init) verbatim so a single adapter instance can serve mixed file:// + https:// roots.
  2. For file: URLs, translate the URL to a filesystem path via node:url.fileURLToPath and return a real Response shaped as the universal-tier plugin code expects: status: 200, body as the file's bytes, headers carrying content-length and last-modified (RFC 7231 IMF-fixdate from node:fs/promises.stat().mtime).
  3. Honour the Range: bytes=<offset>-<end> request header. Both the closed form (bytes=10-19) AND the open form (bytes=10-, interpreted as "through end of file" per RFC 7233 §2.1) SHALL be supported. Successful range reads SHALL return 206 Partial Content with content-length AND content-range: bytes <start>-<end>/<size>. A range whose start is past EOF OR whose start exceeds its end SHALL return 416 Range Not Satisfiable with content-range: bytes */<size>. A malformed Range header SHALL fall back to a full 200 response (the plugin's range pipeline already handles 200 fallback). End values past EOF SHALL be clamped to size - 1.
  4. Honour HEAD requests by returning status: 200 with the same content-length and last-modified headers and no body.
  5. Thread init.signal into node:fs/promises.readFile({ signal }). A pre-aborted signal SHALL short-circuit before the stat round trip and throw the signal's reason (if it is an Error) OR an AbortError. Mid-flight aborts that propagate from fs.readFile as native AbortError SHALL pass through unchanged so the plugin's wrapAbortError upstream rewrites them to BUNDLE_URI.ABORTED.
  6. Map filesystem failures (ENOENT, non-file inode, permission errors) to status: 404 so the plugin's existing NOT_FOUND mapping fires. The adapter SHALL NOT throw HTTP-shaped errors directly.

The subpath SHALL declare node:fs/promises and node:url as runtime-only externals in the package's bundler config so the universal-tier default export never pulls them through tree-shaking.

Scenario: file:// URL returns 200 with file bytes and content-length

  • WHEN nodeFileFetch(new URL("file://<tmp>/club.bin")) is invoked AND club.bin exists with a known 20-byte payload
  • THEN the result is a Response with status === 200, headers.get("content-length") === "20", AND arrayBuffer() byte-equal to the on-disk payload

Scenario: HEAD returns headers only

  • WHEN nodeFileFetch(fileUrl, { method: "HEAD" }) is invoked
  • THEN the result has status === 200, headers.get("content-length") matches the file size, AND arrayBuffer().byteLength === 0

Scenario: closed-form Range returns 206 with the slice

  • WHEN nodeFileFetch(fileUrl, { headers: { range: "bytes=5-9" } }) is invoked against a 20-byte file
  • THEN the result has status === 206, headers.get("content-range") === "bytes 5-9/20", AND the body is byte-equal to the file's [5, 10) slice

Scenario: open-form bytes=<start>- reads through EOF

  • WHEN nodeFileFetch(fileUrl, { headers: { range: "bytes=15-" } }) is invoked against a 20-byte file
  • THEN the result has status === 206, headers.get("content-range") === "bytes 15-19/20", AND the body is the trailing 5 bytes

Scenario: start past EOF maps to 416

  • WHEN nodeFileFetch(fileUrl, { headers: { range: "bytes=20-" } }) is invoked against a 20-byte file
  • THEN the result has status === 416 AND headers.get("content-range") === "bytes */20"

Scenario: missing file maps to 404

  • WHEN nodeFileFetch(new URL("file://<tmp>/nope.bin")) is invoked AND the path does not exist
  • THEN the result has status === 404

Scenario: directory path maps to 404

  • WHEN nodeFileFetch(directoryUrl) is invoked against a file:// URL pointing at a directory (not a regular file)
  • THEN the result has status === 404

Scenario: pre-aborted signal short-circuits

  • WHEN nodeFileFetch(fileUrl, { signal: AbortSignal.abort() }) is invoked
  • THEN the call throws synchronously with an Error whose name === "AbortError" OR with the signal's custom reason if one was provided

Scenario: non-file: URL delegates to globalThis.fetch

  • WHEN nodeFileFetch(new URL("/echo", "http://localhost:<port>/")) is invoked against a real node:http fixture server
  • THEN the call resolves through globalThis.fetch AND the response body matches the fixture's reply

Scenario: plugin end-to-end on Node against a file:// root

  • WHEN bundleUriPlugin({ packageSlug: "x", rootUrl: <tmp file:// URL>, fetch: nodeFileFetch }) is constructed AND adapter.get("club.wav") is invoked
  • THEN the result is a Uint8Array byte-equal to the on-disk bytes (the test suite uses this exact wiring to exercise the file:// scenarios on the universal-tier shim under Node)

Verified by: src/node-fetch/node-fetch.test.ts::"reads file bytes verbatim" AND src/node-fetch/node-fetch.test.ts::"returns headers without body" AND src/node-fetch/node-fetch.test.ts::"honours bytes=<start>-<end> with 206" AND src/node-fetch/node-fetch.test.ts::"open-ended bytes=<start>- reads through EOF" AND src/node-fetch/node-fetch.test.ts::"start past EOF returns 416" AND src/node-fetch/node-fetch.test.ts::"missing file returns 404" AND src/node-fetch/node-fetch.test.ts::"directory path returns 404" AND src/node-fetch/node-fetch.test.ts::"pre-aborted signal throws AbortError" AND src/node-fetch/node-fetch.test.ts::"delegates http:// to globalThis.fetch" AND src/storage-shim/storage-shim.test.ts::"file:// root returns bytes verbatim against real temp dir"


Requirement: ./node-fetch subpath emits a runnable .js artifact in dist/

The package's build SHALL emit dist/node-fetch/node-fetch.js (a real, non-empty ECMAScript module) alongside dist/node-fetch/node-fetch.d.ts so that runtime importers of @suaveplan/storage-uri-plugin-bundle/node-fetch resolve to a runnable file. The vite.config.ts SHALL declare src/node-fetch/node-fetch.ts as an explicit additional library entry — the universal-tier root barrel (src/index.ts) deliberately does NOT re-export nodeFileFetch (to keep node:* imports out of the universal surface), so rollup cannot discover the file via the root entry alone. The build SHALL preserve the existing universal-tier guarantee: dist/index.js SHALL still contain zero node:* imports.

Scenario: build emits a non-empty dist/node-fetch/node-fetch.js

  • WHEN the test runs node:fs.statSync(<package-root>/dist/node-fetch/node-fetch.js) after a clean bunx turbo build --filter=@suaveplan/storage-uri-plugin-bundle
  • THEN the stat succeeds, .isFile() is true, AND .size > 0

Scenario: emitted .js is dynamically importable and exports nodeFileFetch

  • WHEN the test invokes await import(<package-root>/dist/node-fetch/node-fetch.js) after a clean build
  • THEN the import resolves AND typeof imported.nodeFileFetch === "function"

Scenario: universal-tier root barrel remains node-free

  • WHEN the test reads dist/index.js and searches for node: (substring match, case-sensitive)
  • THEN no match is found (the root barrel imports no node:* modules; only the subpath entry pulls them)

Verified by: tests/build/node-fetch-emit.test.ts::"build emits a non-empty dist/node-fetch/node-fetch.js" AND tests/build/node-fetch-runtime-import.test.ts::"emitted node-fetch.js exports nodeFileFetch as a function" AND tests/build/node-fetch-emit.test.ts::"dist/index.js contains no node: imports"