562 lines
33 KiB
Markdown
562 lines
33 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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 `get`s 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|