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 callableparse, a callablebuild, and anadapterthat is structurally aStorageLike
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
BundleUriErrorwithcode === "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
BundleUriErrorwithcode === "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
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"
Scenario: empty rel-path rejected
- WHEN
parse("bundle://audio-ir-room-plugin-club/")orparse("bundle://audio-ir-room-plugin-club")is called - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.INVALID_URI"
Scenario: missing authority rejected
- WHEN
parse("bundle:///assets/club.wav")is called - THEN the call throws
BundleUriErrorwithcode === "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 toparse("bundle://other-pkg/x") - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.PACKAGE_MISMATCH",packageSlug === "audio-ir-room-plugin-club", ANDactual === "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 withpackageSlug: "x" - THEN the call throws
BundleUriErrorwithcode === "BUNDLE_URI.PATH_TRAVERSAL"
Scenario: pct-encoded .. rejected after decode
- WHEN
parse("bundle://x/%2E%2E/etc/passwd")is called against a plugin withpackageSlug: "x" - THEN the call throws
BundleUriErrorwithcode === "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
BundleUriErrorwithcode === "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
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "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
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "setPartial"
Scenario: delete throws READ_ONLY
- WHEN
adapter.delete("assets/x.wav")is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "delete"
Scenario: clear throws READ_ONLY
- WHEN
adapter.clear()is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "clear"
Scenario: list throws READ_ONLY
- WHEN
adapter.list({ prefix: "assets/" })is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.READ_ONLY"ANDop === "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
FetchLikeresolves tonew Response(new Uint8Array([1,2,3]), { status: 200 })forhttp://localhost:<port>/club.wav - AND the caller invokes
await adapter.get("club.wav") - THEN the result is a
Uint8Arraybyte-equal to[1,2,3]
Scenario: file:// root returns bytes verbatim
- WHEN a real temp-dir
file://rootUrlis configured ANDclub.wavexists at<rootUrl>/club.wavwith bytes[9,8,7] - AND the caller invokes
await adapter.get("club.wav")with aFetchLikecapable of readingfile:URLs (the runtime's nativefetchon Bun, OR thenodeFileFetchadapter shipped at the./node-fetchsubpath on Node) - THEN the result is a
Uint8Arraybyte-equal to[9,8,7]
NOTE (corrected post-ship 2026-05-17): Node's undici-backed global
fetchdoes NOT supportfile:URLs in any release through Node 24 (the originaldesign.md §3claim of "Node ≥ 21 native support" was incorrect). Node consumers with afile://rootUrlMUST pass thenodeFileFetchadapter from@suaveplan/storage-uri-plugin-bundle/node-fetchasoptions.fetch. Bun's nativefetchsupportsfile:directly so the default wiring works there.
Scenario: 404 → NOT_FOUND
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 404 }) - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.NOT_FOUND"
Scenario: 500 → FETCH_FAILED with httpStatus
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 500 }) - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.FETCH_FAILED"ANDhttpStatus === 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
FetchLikeresolves aHEADtonew Response(null, { status: 200 }) - THEN
await adapter.exists("k")istrue
Scenario: HEAD 404 yields exists=false
- WHEN the injected
FetchLikeresolves aHEADtonew Response(null, { status: 404 }) - THEN
await adapter.exists("k")isfalse
Scenario: getMetadata parses headers
- WHEN the injected
FetchLikeresolves aHEADtonew 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
FetchLikehonoursRange: bytes=10-19withnew 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
FetchLikeignores theRangeheader and returnsnew Response(<100 bytes>, { status: 200 }) - AND the caller invokes
adapter.getPartial("k", 10, 10) - THEN the returned bytes equal
body.slice(10, 20)ANDbundle_uri.range_fallback_total{root_kind:"http"}is incremented by 1
Scenario: 416 maps to RANGE_INVALID
- WHEN the injected
FetchLikeresolves tonew Response(null, { status: 416 }) - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.RANGE_INVALID"
Scenario: invalid offset rejected before fetch
- WHEN
adapter.getPartial("k", -1, 10)is called - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.RANGE_INVALID"AND the injectedFetchLikeis NEVER invoked
Scenario: data: root always slow-pathed
- WHEN the rootUrl is a
data:URL ANDadapter.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
FetchLikereturns aResponsewith a non-nullbody - 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
FetchLikereturns aResponsewhosebodyisnullAND whosearrayBuffer()resolves toUint8Array([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
BundleUriErrorwhosecode === "BUNDLE_URI.ABORTED"AND the injectedFetchLikeis NEVER invoked
Scenario: mid-flight abort propagates on cause
- WHEN
adapter.get("k", controller.signal)is called ANDcontroller.abort()fires before the response resolves ANDfetchrejects with anAbortError - THEN the call rejects with
BundleUriErrorwhosecode === "BUNDLE_URI.ABORTED"AND whosecauseis the originalAbortError
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.cacheAND the caller invokesget("k")twice - THEN the injected
FetchLikeis invoked twice ANDbundle_uri.cache_hits_totalstays at 0
Scenario: cache hit returns cached bytes without fetch
- WHEN the plugin is constructed with
options.cache = { maxBytes: 1024 }AND the caller invokesget("k")twice - THEN the injected
FetchLikeis invoked exactly once ANDbundle_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 threegetcalls insert entries of size 50, 50, 50 in order - THEN after the third insert, the first entry (50 bytes) is evicted AND a subsequent
getfor 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 ofbundle_uri.reads_total{root_kind:"http", outcome:"success"}
Scenario: error increments errors_total with resolved code
- WHEN
get("k")rejects withNOT_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_pathattribute 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 successfulgetrecords a span - THEN the span's
bundle.root_kindattribute 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"callsbuild("assets/y.wav") - THEN the result is
"bundle://x/assets/y.wav"ANDparse("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 afile://rootUrl pointing at a temp dir containinga.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 realnode:httpfixture 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:
- Inspect the input URL's protocol; non-
file:URLs SHALL delegate toglobalThis.fetch(input, init)verbatim so a single adapter instance can serve mixedfile://+https://roots. - For
file:URLs, translate the URL to a filesystem path vianode:url.fileURLToPathand return a realResponseshaped as the universal-tier plugin code expects:status: 200, body as the file's bytes, headers carryingcontent-lengthandlast-modified(RFC 7231 IMF-fixdate fromnode:fs/promises.stat().mtime). - 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 return206 Partial Contentwithcontent-lengthANDcontent-range: bytes <start>-<end>/<size>. A range whose start is past EOF OR whose start exceeds its end SHALL return416 Range Not Satisfiablewithcontent-range: bytes */<size>. A malformedRangeheader SHALL fall back to a full200response (the plugin's range pipeline already handles 200 fallback). End values past EOF SHALL be clamped tosize - 1. - Honour
HEADrequests by returningstatus: 200with the samecontent-lengthandlast-modifiedheaders and no body. - Thread
init.signalintonode:fs/promises.readFile({ signal }). A pre-aborted signal SHALL short-circuit before thestatround trip and throw the signal'sreason(if it is anError) OR anAbortError. Mid-flight aborts that propagate fromfs.readFileas nativeAbortErrorSHALL pass through unchanged so the plugin'swrapAbortErrorupstream rewrites them toBUNDLE_URI.ABORTED. - Map filesystem failures (
ENOENT, non-file inode, permission errors) tostatus: 404so the plugin's existingNOT_FOUNDmapping 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 ANDclub.binexists with a known 20-byte payload - THEN the result is a
Responsewithstatus === 200,headers.get("content-length") === "20", ANDarrayBuffer()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, ANDarrayBuffer().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 === 416ANDheaders.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 afile://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
Errorwhosename === "AbortError"OR with the signal's customreasonif one was provided
Scenario: non-file: URL delegates to globalThis.fetch
- WHEN
nodeFileFetch(new URL("/echo", "http://localhost:<port>/"))is invoked against a realnode:httpfixture server - THEN the call resolves through
globalThis.fetchAND 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 ANDadapter.get("club.wav")is invoked - THEN the result is a
Uint8Arraybyte-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 cleanbunx turbo build --filter=@suaveplan/storage-uri-plugin-bundle - THEN the stat succeeds,
.isFile()istrue, 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.jsand searches fornode:(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"