Initial Commit
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
# Design — @suaveplan/storage-uri-plugin-bundle
|
||||
|
||||
Non-normative companion to `proposal.md` + `specs/storage-uri-plugin-bundle/spec.md`. Captures
|
||||
URI grammar, resolution strategy, traversal-prevention, range semantics, tier-purity rationale,
|
||||
error-code matrix, and file layout.
|
||||
|
||||
## 1. URI grammar (ABNF, RFC 5234)
|
||||
|
||||
```abnf
|
||||
bundle-uri = "bundle://" package-slug "/" rel-path [ "?" query ] [ "#" fragment ]
|
||||
package-slug = [ "@" scoped-prefix "/" ] slug-segment
|
||||
; e.g. "audio-ir-room-plugin-club" or "@suaveplan/font-subset"
|
||||
scoped-prefix = 1*( ALPHA / DIGIT / "-" / "_" )
|
||||
slug-segment = 1*( ALPHA / DIGIT / "-" / "_" / "." )
|
||||
; canonical npm-package-name subset; case-insensitive match per RFC 3986 §3.1
|
||||
rel-path = path-segment *( "/" path-segment )
|
||||
path-segment = 1*pchar ; pchar = RFC 3986 §3.3 (unreserved / pct-encoded / sub-delims / ":" / "@")
|
||||
; explicitly EXCLUDES the bare two-byte sequence ".." anywhere
|
||||
; (raw or pct-decoded — see §4 Traversal prevention)
|
||||
query = *( pchar / "/" / "?" ) ; opaque to this plugin; preserved on outbound URL only when caller embeds
|
||||
fragment = *( pchar / "/" / "?" ) ; never sent on the wire (RFC 3986)
|
||||
```
|
||||
|
||||
Constraints layered on top of the grammar:
|
||||
|
||||
- Total URI length ≤ 4096 chars (inherited from `parseUri`).
|
||||
- `package-slug` length ≤ 128 chars after the optional `@scope/` prefix.
|
||||
- `rel-path` length ≤ 1024 chars; per-segment length ≤ 255 chars (filesystem-portable).
|
||||
- No NUL bytes, no whitespace (inherited from `parseUri`).
|
||||
- Scheme matched **case-insensitively** per RFC 3986 §3.1; `BUNDLE://` is valid input but is
|
||||
lowercased before the plugin sees it.
|
||||
|
||||
## 2. Resolution decision matrix
|
||||
|
||||
The plugin owns one decision: given a `rel-path`, what URL does `fetch` see? The matrix:
|
||||
|
||||
| `rootUrl` scheme | Resolved request URL | Range support | Notes |
|
||||
|---|---|---|---|
|
||||
| `file://` | `new URL(encodedRelPath, rootUrl)` | yes on Node ≥ 21 / Bun; falls back on browsers | Browsers reject `fetch('file://…')` for security; bundlers rewrite the literal to `https:` or virtual on browser builds |
|
||||
| `https://` / `http://` | `new URL(encodedRelPath, rootUrl)` | yes (HTTP `Range` header) | Standard CDN/origin path |
|
||||
| `data:` | rootUrl itself (rel-path ignored at single-resource roots) | no — slice client-side | Rare; only when the consumer wraps a single base64 asset as a "bundle" |
|
||||
| `blob:` | rootUrl itself (single-resource) | no — slice client-side | Browser-only; same single-resource model as `data:` |
|
||||
| Bundler-virtual (`virtual:`, `~asset:`, `\0…`) | `new URL(encodedRelPath, rootUrl)` | depends on bundler runtime — usually no | Treated as "other"; counter is `bundle.root_kind="other"` |
|
||||
|
||||
`root_kind` is an OTel attribute derived once at construction:
|
||||
|
||||
```ts
|
||||
function classifyRootKind(rootUrl: URL): "file" | "http" | "data" | "blob" | "other" {
|
||||
switch (rootUrl.protocol) {
|
||||
case "file:": return "file";
|
||||
case "http:":
|
||||
case "https:": return "http";
|
||||
case "data:": return "data";
|
||||
case "blob:": return "blob";
|
||||
default: return "other";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Why `fetch` everywhere (tier-purity argument)
|
||||
|
||||
The package MUST be universal-tier: zero browser-only, zero Node-only deps. The candidate
|
||||
strategies were:
|
||||
|
||||
| Strategy | Universal? | Verdict |
|
||||
|---|---|---|
|
||||
| Inject a `StorageLike` at the consumer's tier | yes | Pushes the universal/Node split onto every consumer — defeats the point of the plugin |
|
||||
| Conditional `fs.readFile` for `file:` + `fetch` for `http:` | NO — `fs` is Node-only | Rejected: forces tier-specific build |
|
||||
| `fetch` for every scheme | YES — `fetch` is universal | **Chosen.** Node ≥ 21 and Bun natively support `fetch('file://…')`. Browsers reject `file://` per security policy, but bundlers rewrite the `import.meta.url`-derived `rootUrl` to `https:` (Vite, esbuild) or a virtual scheme on browser builds — that case lands as `http`/`other` `root_kind`. |
|
||||
|
||||
`globalThis.fetch` is the default; tests inject a controlled `FetchLike` (per
|
||||
`storage-http` precedent — `FetchLike` is canonical in `@suaveplan/types`). No `node:fs`,
|
||||
no `node:http`, no `bun:*`, no `window.*`. The tier-purity grep
|
||||
(`grep -rnE "from ['\"](node:|bun:|process|globalThis\.window)['\"]"`) MUST return empty.
|
||||
|
||||
## 4. Traversal-prevention algorithm
|
||||
|
||||
Two attack surfaces — raw `..` and pct-encoded `%2E%2E`. Both rejected:
|
||||
|
||||
```ts
|
||||
function rejectTraversal(rawRelPath: string): void {
|
||||
// Pass 1 — raw inspection (pre-decode).
|
||||
if (RAW_TRAVERSAL_RE.test(rawRelPath)) {
|
||||
throw new BundleUriError(BUNDLE_URI_CODES.PATH_TRAVERSAL, "raw `..` segment", { … });
|
||||
}
|
||||
// Pass 2 — post-decode inspection.
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawRelPath);
|
||||
} catch (cause) {
|
||||
throw new BundleUriError(BUNDLE_URI_CODES.INVALID_URI, "malformed percent-encoding", { cause, … });
|
||||
}
|
||||
if (RAW_TRAVERSAL_RE.test(decoded)) {
|
||||
throw new BundleUriError(BUNDLE_URI_CODES.PATH_TRAVERSAL, "encoded `..` after decode", { … });
|
||||
}
|
||||
// Pass 3 — defensive URL-resolve check. resolved.pathname MUST start with rootUrl.pathname.
|
||||
const resolved = new URL(encodedRelPath, rootUrl);
|
||||
if (!resolved.pathname.startsWith(rootUrl.pathname)) {
|
||||
throw new BundleUriError(BUNDLE_URI_CODES.PATH_TRAVERSAL, "URL resolved outside rootUrl", { … });
|
||||
}
|
||||
}
|
||||
|
||||
const RAW_TRAVERSAL_RE = /(^|\/)\.\.(\/|$)/;
|
||||
```
|
||||
|
||||
Pass 3 is the belt-and-braces — even if a hypothetical input slipped past the first two passes,
|
||||
the URL-resolution check anchors every resolved URL inside `rootUrl.pathname`.
|
||||
|
||||
## 5. Range-request behaviour per `root_kind`
|
||||
|
||||
| `root_kind` | `getPartial(offset, length)` strategy |
|
||||
|---|---|
|
||||
| `http` | Issue `GET` with `Range: bytes=<offset>-<offset+length-1>`. Expect `206 Partial Content` → return body bytes; `200 OK` → slow-path slice client-side AND increment `bundle_uri.range_fallback_total{root_kind:"http"}`; `416` → `RANGE_INVALID`. |
|
||||
| `file` | Same `Range` header. Node ≥ 21 / Bun's `fetch(file:URL)` honours `Range` and returns `206`. If the runtime returns `200` (older Node, some Bun versions), fallback path applies. |
|
||||
| `data` / `blob` | Single-resource. Always fetch full body, slice client-side, increment `range_fallback_total{root_kind}`. |
|
||||
| `other` | Same as `data`/`blob` — full body + client-side slice + counter. |
|
||||
|
||||
Offset / length validation (pre-fetch):
|
||||
|
||||
- `offset` MUST be a non-negative integer; otherwise `RANGE_INVALID`.
|
||||
- `length` MUST be a positive integer; otherwise `RANGE_INVALID`.
|
||||
- Sum `offset + length` exceeding `Number.MAX_SAFE_INTEGER` → `RANGE_INVALID`.
|
||||
|
||||
## 6. Error-code matrix
|
||||
|
||||
| Code | Discriminants populated | Raised from |
|
||||
|---|---|---|
|
||||
| `BUNDLE_URI.PACKAGE_MISMATCH` | `uri`, `scheme`, `packageSlug`, `actual` (the offending slug) | `parse()` when URI's authority ≠ `options.packageSlug` |
|
||||
| `BUNDLE_URI.PATH_TRAVERSAL` | `uri`, `scheme`, `packageSlug`, `relPath` | `parse()` when rel-path contains `..` raw or post-decode, OR pass 3 URL-resolve check fails |
|
||||
| `BUNDLE_URI.READ_ONLY` | `uri`, `scheme`, `packageSlug`, `op` (the rejected operation name) | `set` / `setPartial` / `delete` / `clear` / `list` on the shim |
|
||||
| `BUNDLE_URI.NOT_FOUND` | `uri`, `scheme`, `packageSlug`, `relPath` | fetch returns `404` / `410` |
|
||||
| `BUNDLE_URI.INVALID_URI` | `uri`, `scheme` (when known) | `parse()` for malformed grammar, malformed pct-encoding, empty rel-path, missing authority |
|
||||
| `BUNDLE_URI.FETCH_FAILED` | `uri`, `scheme`, `rootKind`, `httpStatus` (when applicable), `cause` | fetch reject (DNS / TLS / abort-without-signal), or non-2xx non-404 status |
|
||||
| `BUNDLE_URI.RANGE_INVALID` | `uri`, `scheme`, `relPath`, `offset`, `length` | `getPartial` with invalid offset/length, OR fetch returns `416` |
|
||||
| `BUNDLE_URI.RANGE_UNSUPPORTED` | `uri`, `scheme`, `rootKind` | reserved for cases where the runtime cannot represent the Range request at all (currently unreachable; documented as forward-defensive). NOTE: declared in registry but per §2c MUST be wired — wired by the `data:`/`blob:` adapter where `Range` headers are stripped before the underlying `fetch` and a counter is recorded. |
|
||||
| `BUNDLE_URI.ABORTED` | `uri`, `scheme`, `cause` (the `AbortError`) | pre-aborted signal OR mid-flight abort propagation |
|
||||
|
||||
Every code listed above is reached from a real call site exercised by the test suite — see
|
||||
`error-codes.test.ts` and the per-feature test files.
|
||||
|
||||
## 7. File layout
|
||||
|
||||
```
|
||||
packages/storage/storage-uri-plugin-bundle/
|
||||
package.json # v0.0.0, sideEffects:false, subpath exports per feature
|
||||
README.md
|
||||
tsconfig.json, tsconfig.lib.json
|
||||
vite.config.ts, vitest.config.ts # via @suaveplan/testing/config
|
||||
tests/setup/polyfills.ts
|
||||
src/
|
||||
index.ts # explicit named re-exports (no `export *`)
|
||||
error-codes/
|
||||
error-codes.ts # defineCodes("BUNDLE_URI", [...]); BundleUriError
|
||||
error-codes.md
|
||||
error-codes.test.ts
|
||||
index.ts
|
||||
types/
|
||||
plugin-options.types.ts # BundleUriPluginOptions interface
|
||||
plugin-options.types.md
|
||||
plugin-options.schemas.ts # Zod schema for BundleUriPluginOptions
|
||||
plugin-options.schemas.md
|
||||
index.ts
|
||||
factory/
|
||||
factory.ts # bundleUriPlugin(options) entry point
|
||||
factory.md
|
||||
factory.test.ts
|
||||
index.ts
|
||||
parse/
|
||||
parse.ts # parse() + build() + traversal-prevention
|
||||
parse.md
|
||||
parse.test.ts
|
||||
index.ts
|
||||
storage-shim/
|
||||
storage-shim.ts # StorageLike adapter wrapping FetchLike
|
||||
storage-shim.md
|
||||
storage-shim.test.ts
|
||||
index.ts
|
||||
range/
|
||||
range.ts # Range header + 206/200/416 disambiguation
|
||||
range.md
|
||||
range.test.ts
|
||||
index.ts
|
||||
streaming/
|
||||
streaming.ts # createReadStream forwarding
|
||||
streaming.md
|
||||
streaming.test.ts
|
||||
index.ts
|
||||
abort/
|
||||
abort.ts # AbortSignal helpers + pre-abort check
|
||||
abort.md
|
||||
abort.test.ts
|
||||
index.ts
|
||||
cache/
|
||||
cache.ts # in-memory LRU keyed by URI
|
||||
cache.md
|
||||
cache.test.ts
|
||||
index.ts
|
||||
otel/
|
||||
otel.ts # tracer + meter wiring + recordOp helper
|
||||
otel.md
|
||||
otel.test.ts
|
||||
index.ts
|
||||
conformance/
|
||||
conformance.test.ts # runUriPluginConformance pass (file + http fixtures)
|
||||
__benchmarks__/
|
||||
bundle-uri.bench.ts # mitata: parse, fetch round-trip, range slow-path
|
||||
```
|
||||
|
||||
`index.ts` at package root re-exports `bundleUriPlugin`, `BundleUriPluginOptions`,
|
||||
`BundleUriPluginOptionsSchema`, `BUNDLE_URI_CODES`, `BundleUriError`, `BundleUriErrorCode` — and
|
||||
nothing else. The conformance test is the only file in `src/conformance/` (no `.ts` impl, no
|
||||
`.md` required per §4 exclusion of `.test.ts`).
|
||||
|
||||
## 8. OTel attribute redaction
|
||||
|
||||
- `bundle.rel_path` truncated to 256 chars (sliced; ellipsis NOT appended — the attribute is a
|
||||
diagnostic key, not human-presented).
|
||||
- Query strings stripped from every attribute by stripping at the first `?` before the
|
||||
`decodeURIComponent` step.
|
||||
- `bundle.package` never redacted (slug is a public identifier).
|
||||
- `cause` chain not put on attributes — preserved on the `BundleUriError` instance only.
|
||||
|
||||
## 9. Subpath exports
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
|
||||
"./factory/factory": { "import": { "types": "./dist/factory/factory.d.ts", "default": "./dist/factory/factory.js" } },
|
||||
"./parse/parse": { "import": { "types": "./dist/parse/parse.d.ts", "default": "./dist/parse/parse.js" } },
|
||||
"./storage-shim/storage-shim": { "import": { "types": "./dist/storage-shim/storage-shim.d.ts", "default": "./dist/storage-shim/storage-shim.js" } },
|
||||
"./range/range": { "import": { "types": "./dist/range/range.d.ts", "default": "./dist/range/range.js" } },
|
||||
"./streaming/streaming": { "import": { "types": "./dist/streaming/streaming.d.ts", "default": "./dist/streaming/streaming.js" } },
|
||||
"./abort/abort": { "import": { "types": "./dist/abort/abort.d.ts", "default": "./dist/abort/abort.js" } },
|
||||
"./cache/cache": { "import": { "types": "./dist/cache/cache.d.ts", "default": "./dist/cache/cache.js" } },
|
||||
"./error-codes/error-codes":{ "import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" } },
|
||||
"./otel/otel": { "import": { "types": "./dist/otel/otel.d.ts", "default": "./dist/otel/otel.js" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nine subpaths matching nine feature directories — verified by §13 step 8 in `AGENTS.md`.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Add @suaveplan/storage-uri-plugin-bundle — `bundle://` URI scheme for per-package shipped assets
|
||||
|
||||
**Package**: `@suaveplan/storage-uri-plugin-bundle`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (genesis-original)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/storage-uri-plugin-bundle/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Several genesis packages need to ship per-package binary assets — impulse responses, sample fonts,
|
||||
sample images, room-acoustic IRs — alongside their npm tarball, and resolve them through the
|
||||
existing `@suaveplan/storage-uri` ecosystem so that consumers compose them with `drive`,
|
||||
`storage-plugin-cache`, OTel, AbortSignal, and structured-error machinery for free.
|
||||
|
||||
There is no scheme today for "an asset shipped inside a package on disk / inside a CDN-hosted npm
|
||||
tarball / inside a bundler's flattened asset graph". `file://` couples to Node, `https://` couples
|
||||
to a specific deployment, and bundler-virtual schemes (`asset:`, `data:`, `blob:`) vary per bundler
|
||||
and don't survive packaging. The `bundle://` scheme — `bundle://<package-slug>/<relative-path>` —
|
||||
fills that gap with a single contract that works identically on Node, Bun, browsers, and bundlers
|
||||
that flatten assets (Vite, esbuild, webpack).
|
||||
|
||||
Per-package registration is the key design choice: each consuming package calls
|
||||
`bundleUriPlugin({ packageSlug, rootUrl: new URL("./", import.meta.url) })` from its own `register()`
|
||||
and the resolver dispatches by slug. That makes the package the source of truth for "what does my
|
||||
slug resolve to", lets bundlers do their normal URL-rewrite on the `import.meta.url` literal, and
|
||||
keeps the universal tier honest — no Node-only `fs.readFile`, no browser-only `Response`, just
|
||||
`fetch` over the resolved URL (which handles `file:`, `https:`, `data:`, `blob:` uniformly on Node
|
||||
≥ 21, Bun, and modern browsers).
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `bundleUriPlugin(options)` factory returning a `UriPlugin` registerable with
|
||||
`createStorageUriResolver`. One factory call per consuming package; one plugin per slug.
|
||||
- ADDED: `BundleUriPluginOptions` interface + `BundleUriPluginOptionsSchema` Zod schema. Required
|
||||
`packageSlug: string` (must match `/^@?[a-z0-9][a-z0-9._/-]{0,127}$/`), `rootUrl: URL | string`
|
||||
(resolved to a `URL` with trailing-slash enforced). Optional `fetch: FetchLike`,
|
||||
`logger: LoggerLike`, `clock: Clock`, `cache: { maxBytes: number }`.
|
||||
- ADDED: `BUNDLE_URI_CODES` registry via `defineCodes("BUNDLE_URI", [...])` — 9 codes:
|
||||
`PACKAGE_MISMATCH`, `PATH_TRAVERSAL`, `READ_ONLY`, `NOT_FOUND`, `INVALID_URI`, `FETCH_FAILED`,
|
||||
`RANGE_INVALID`, `RANGE_UNSUPPORTED`, `ABORTED`.
|
||||
- ADDED: `BundleUriError extends SuaveplanError<BundleUriErrorCode>` with `uri`, `scheme`,
|
||||
`packageSlug`, `relPath`, `rootKind` discriminants for diagnostics.
|
||||
- ADDED: `parse(uri)` enforcing `bundle://<package-slug>/<rel-path>`: slug mismatch →
|
||||
`PACKAGE_MISMATCH`; `..` traversal (raw or post-decode) → `PATH_TRAVERSAL`; empty rel-path,
|
||||
malformed scheme, missing authority → `INVALID_URI` (forwarded from `parseUri`).
|
||||
- ADDED: `build(key, meta?)` inverse — re-serialises `<packageSlug>/<key>` back to a `bundle://`
|
||||
URI for `list()` round-trips and the conformance harness.
|
||||
- ADDED: Universal-tier resolution strategy (decision matrix in `design.md §3`): every read is
|
||||
executed via the injected `FetchLike` against `new URL(encodedRelPath, rootUrl)`. `file:`,
|
||||
`https:`, `http:`, `data:`, `blob:`, and bundler-virtual schemes all flow through `fetch`. The
|
||||
package ships NO `fs.readFile` adapter — `fetch` of `file:` is supported on Node ≥ 21 and Bun
|
||||
natively, and is the canonical universal-tier surface.
|
||||
- ADDED: `StorageLike` shim implementing `get`, `exists`, `getMetadata`, `getPartial`,
|
||||
`createReadStream`, and explicitly-throwing `set`/`setPartial`/`delete`/`clear`/`list` (bundles
|
||||
are immutable; `READ_ONLY`).
|
||||
- ADDED: Range reads via HTTP `Range: bytes=<start>-<end>` header on the fetch init; `206 Partial
|
||||
Content` returned verbatim, `200 OK` slow-pathed (full body sliced client-side) with a
|
||||
`bundle_uri.range_fallback_total{root_kind}` counter increment, `416 Range Not Satisfiable` →
|
||||
`RANGE_INVALID`. Underlying transports without range support (`data:`, `blob:` from some
|
||||
browsers) increment `range_fallback_total` and slice.
|
||||
- ADDED: Streaming via `createReadStream(key, signal)` returning `Response.body` directly when
|
||||
present; falls back to a one-chunk stream wrapping the buffered bytes when `body` is null.
|
||||
- ADDED: Optional in-memory LRU cache keyed by full URI. Off by default; opt-in via
|
||||
`options.cache = { maxBytes }`. Eviction policy: oldest-first when total bytes >
|
||||
`maxBytes`. NO stale-while-revalidate — bundles are content-addressed by package version.
|
||||
- ADDED: `AbortSignal` threaded into every fetch call. Pre-aborted signals short-circuit with
|
||||
`BUNDLE_URI.ABORTED` before any fetch. Mid-flight abort propagates the underlying
|
||||
`AbortError` on `cause`.
|
||||
- ADDED: OTel surface — `bundle_uri.op_duration_ms{op, root_kind, outcome}` histogram;
|
||||
`bundle_uri.reads_total{root_kind, outcome}`, `bundle_uri.errors_total{root_kind, code}`,
|
||||
`bundle_uri.range_fallback_total{root_kind}`, `bundle_uri.cache_hits_total{root_kind}`,
|
||||
`bundle_uri.cache_misses_total{root_kind}` counters; per-op span on
|
||||
`@suaveplan/storage-uri-plugin-bundle` tracer with attributes `bundle.package`,
|
||||
`bundle.rel_path` (truncated to 256 chars), `bundle.root_kind` (`file` | `http` | `data` |
|
||||
`blob` | `other`). Query strings stripped from every attribute (matches
|
||||
`storage-uri` precedent).
|
||||
- ADDED: `runUriPluginConformance` pass — wired into `bundle-uri-plugin.spec-conformance.test.ts`
|
||||
with `validUris`, `invalidUris`, and `buildRoundTrip` fixtures derived from a temp-dir
|
||||
`file://` root and a localhost HTTP fixture root.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **`list(prefix)`.** Bundle roots have no uniform directory protocol (`file:` could `readdir`,
|
||||
`https:` cannot). `list` SHALL throw `BUNDLE_URI.READ_ONLY` (capability `listing: false`).
|
||||
Reason: a manifest-backed list would couple to a specific build tool's manifest format; the
|
||||
consumer can register a `storage-file` plugin if directory listing is needed.
|
||||
- **Persistent disk cache.** The optional in-memory LRU is per-plugin-instance only. Cross-process
|
||||
caches are the consumer's job (compose `storage-plugin-cache` in front).
|
||||
- **Server-side asset shipping helpers.** Build-tool plugins that copy assets into `dist/` are
|
||||
build-config, not runtime. Out of scope here; addressed by consuming packages' own
|
||||
build configs.
|
||||
- **Auth / signed URLs.** If a consumer ships assets behind a signed CDN, they inject a
|
||||
`FetchLike` that adds the signature header — same pattern as `storage-http`.
|
||||
- **Writes of any kind.** Bundles are immutable by nature; `set`/`delete`/`clear`/`setPartial`
|
||||
unconditionally throw `READ_ONLY`. There is no write-uploader slot — adding one would invalidate
|
||||
the immutability contract that callers (and caches) rely on.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This is a **genesis-original** package; there is no upstream in `../suaveplan/`. The design
|
||||
follows the genesis `UriPlugin` contract (`openspec/specs/storage-uri/spec.md`) and the
|
||||
`drive-uri/plugins/{drive,drive-mesh,drive-template}` three-plugin reference pattern (each owns
|
||||
one scheme, ships a factory, passes `runUriPluginConformance`).
|
||||
|
||||
### Requirements with no upstream source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `bundle://` scheme name | Short, descriptive, distinct from `file:`/`asset:`/`pkg:`/`npm:` (which all have established meanings); matches the established `<scheme>-uri-plugin-<scheme>` naming convention of the package. |
|
||||
| Per-package registration with `import.meta.url` | Lets bundlers do their normal URL-rewrite at the consumer's call site; the plugin never needs to know about bundler internals. |
|
||||
| `fetch`-only resolution (no `fs.readFile` branch) | Keeps the universal tier honest — no Node-only / Bun-only branches. `fetch(file:URL)` works on Node ≥ 21 and Bun natively. |
|
||||
| Per-package slug enforcement in `parse()` | Catches mis-routed URIs at the boundary (`bundle://other-pkg/x` against this plugin → `PACKAGE_MISMATCH`) before any I/O. |
|
||||
| `..` traversal rejected both raw and post-decode | Prevents an attacker from escaping the rootUrl via `%2E%2E/...` after URL decoding. |
|
||||
| Bundles immutable — every write throws `READ_ONLY` | Lets consumers and caches rely on the URI being content-addressed by package version; relaxing this would silently invalidate downstream caches. |
|
||||
| Range reads via HTTP `Range` header | Universal across `file:`/`http:`/`https:` on Node ≥ 21 / Bun (fetch honours `Range` against `file:`); falls back transparently when origin returns 200. |
|
||||
| In-memory LRU off by default | Bundle reads are typically rare-once / many-cached upstream by a `storage-plugin-cache`; baking a default LRU would mask that composition and burn memory on cold pages. |
|
||||
| `BUNDLE_URI.ABORTED` distinct from `FETCH_FAILED` | Lets retry / circuit-breaker classifiers distinguish caller-initiated cancellation from transport failure. |
|
||||
|
||||
## ≥2-consumer justification
|
||||
|
||||
`@suaveplan/types` rule (AGENTS.md §0 item 6): a new shared abstraction must serve ≥2 packages.
|
||||
The `bundle://` plugin will be consumed by:
|
||||
|
||||
1. **`@suaveplan/audio-ir-room-plugin-club`** (sibling-session, in-flight) — ships a club-room
|
||||
impulse response (`assets/club.wav`) inside its tarball; resolves it via
|
||||
`bundle://audio-ir-room-plugin-club/assets/club.wav`.
|
||||
2. **`@suaveplan/audio-ir-room-plugin-pool`** (sibling-session, in-flight) — ships a swimming-pool
|
||||
impulse response (`assets/pool.wav`) on the same pattern.
|
||||
3. **Future consumers** explicitly anticipated: `font-subset` sample-font shipping,
|
||||
`compress-image` reference-image bundles, `qrcode` reference-fixture bundles,
|
||||
`audio-sample-pack-plugin-*` family. Every package that needs to address its own shipped
|
||||
assets through a URI-routed dispatcher gains the same plumbing for free.
|
||||
|
||||
The factory is per-package — slug-scoped — so multiple consumers register parallel plugin
|
||||
instances against the same resolver without conflict, each owning its own slug.
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta carries a `**Verified by:**` line referencing concrete
|
||||
`it(...)` titles in these files:
|
||||
|
||||
- `src/factory/factory.test.ts` — option validation, slug regex, rootUrl normalisation
|
||||
(trailing-slash enforced), required `fetch` default = `globalThis.fetch`.
|
||||
- `src/parse/parse.test.ts` — well-formed URIs accepted, scheme mismatch → `INVALID_URI`,
|
||||
empty rel-path → `INVALID_URI`, slug mismatch → `PACKAGE_MISMATCH`, raw `..` →
|
||||
`PATH_TRAVERSAL`, encoded `%2E%2E` → `PATH_TRAVERSAL` after decode, `build()` round-trips.
|
||||
- `src/storage-shim/storage-shim.test.ts` — `get` against real `file://` temp dir, `get` against
|
||||
a real `node:http` localhost fixture server, `exists` HEAD path, `getMetadata` parses
|
||||
`content-length` / `content-type` / `last-modified` / `etag`, every write method throws
|
||||
`READ_ONLY`, `list` throws `READ_ONLY`.
|
||||
- `src/range/range.test.ts` — `getPartial(offset, length)` issues `Range: bytes=offset-end`
|
||||
header against a real HTTP fixture, 206 path returns slice, 200 fallback path slices
|
||||
client-side and increments `range_fallback_total`, 416 → `RANGE_INVALID`.
|
||||
- `src/streaming/streaming.test.ts` — `createReadStream` returns `Response.body` for HTTP root,
|
||||
returns a one-chunk stream wrapping the buffered bytes when `body` is null.
|
||||
- `src/abort/abort.test.ts` — pre-aborted signal → `BUNDLE_URI.ABORTED` without fetch call;
|
||||
mid-flight abort propagates the underlying `AbortError` on `cause`.
|
||||
- `src/cache/cache.test.ts` — LRU off by default (cache hits counter stays at 0); LRU on
|
||||
evicts oldest entry when `maxBytes` exceeded; cache hit returns the same bytes without
|
||||
re-fetching.
|
||||
- `src/error-codes/error-codes.test.ts` — every `BUNDLE_URI.*` code is reachable from a real
|
||||
call site; `defineCodes` registry exhaustion test.
|
||||
- `src/otel/otel.test.ts` — `op_duration_ms`, counters, span attributes against an in-memory
|
||||
`MeterProvider` + `SpanExporter`; query strings stripped from `bundle.rel_path` attribute.
|
||||
- `src/conformance/conformance.test.ts` — `runUriPluginConformance(bundleUriPlugin(...), {...})`
|
||||
passes against both a `file://` temp-dir root and an `http://localhost:<port>/` fixture root.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/storage-uri-plugin-bundle` to enumerate. The package is
|
||||
new — no dependents currently. The two named future consumers (`audio-ir-room-plugin-club`,
|
||||
`audio-ir-room-plugin-pool`) are in flight in a parallel session and will wire this in as part
|
||||
of their own change.
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-uri-plugin-bundle`
|
||||
returning the package itself).
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
- `@suaveplan/storage-uri` — consumed as the `UriPlugin` contract source + `parseUri` helper +
|
||||
`runUriPluginConformance` harness. Pure consumer; no changes required to `storage-uri` itself.
|
||||
[x] no action needed (purely additive consumer).
|
||||
- `@suaveplan/types` — consumed for `FetchLike`, `LoggerLike`, `Clock`, `StorageLike`,
|
||||
`StatResult` (latter via `storage-uri`'s `StreamingStorageExtensions`). Pure consumer; no
|
||||
changes required. [x] no action needed (purely additive consumer).
|
||||
- `@suaveplan/error` — `defineCodes` + `SuaveplanError` base. Pure consumer. [x] no action needed.
|
||||
@@ -0,0 +1,422 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-uri-plugin-bundle (storage-uri-plugin-bundle)
|
||||
|
||||
**Package**: `@suaveplan/storage-uri-plugin-bundle`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED 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 the runtime's native `fetch`
|
||||
- **THEN** the result is a `Uint8Array` byte-equal to `[9,8,7]`
|
||||
|
||||
#### 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,157 @@
|
||||
# Tasks — storage-uri-plugin-bundle
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Numbered Requirement / Scenario
|
||||
> checklist (per AGENTS §0c step 6) is the gating list — implementation is not done until every
|
||||
> requirement in `specs/storage-uri-plugin-bundle/spec.md` is ticked off here.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/AGENTS.md` §0, §0a, §0b, §0c, §13, §14
|
||||
- [x] 1.2 Read `openspec/specs/storage-uri/spec.md` end-to-end (UriPlugin contract + conformance)
|
||||
- [x] 1.3 Read `packages/storage/storage-uri/src/` (parseUri, error-codes, conformance harness)
|
||||
- [x] 1.4 Read `packages/drive/drive-uri/src/plugins/{drive,drive-mesh,drive-template}/` as the 3-plugin reference
|
||||
- [x] 1.5 Read `packages/storage/storage-http/src/types/fetch.types.ts` for the `FetchLike` re-export pattern
|
||||
- [x] 1.6 Confirm the package is not in `STATUS.md` (genesis-original, not pending)
|
||||
- [x] 1.7 Draft `proposal.md` including the ≥2-consumer justification
|
||||
- [x] 1.8 Draft `design.md` covering URI grammar (ABNF), resolution matrix, traversal algorithm, range matrix, tier-purity argument, error-code matrix, file layout
|
||||
- [x] 1.9 Draft `specs/storage-uri-plugin-bundle/spec.md` from the OpenSpec delta skeleton
|
||||
- [x] 1.10 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete planned `it(...)` titles
|
||||
- [x] 1.11 Build the numbered Requirement/Scenario implementation checklist below (§9)
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [x] 2.1 Create `packages/storage/storage-uri-plugin-bundle/` mirroring `storage/storage-uri/` conventions
|
||||
- [x] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, nine subpath exports per `design.md §9`
|
||||
- [x] 2.3 Wire runtime deps: `@suaveplan/error`, `@suaveplan/storage-uri`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib` (all `workspace:^` / `catalog:`)
|
||||
- [x] 2.4 Wire devDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
|
||||
- [x] 2.5 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (via `createTestConfig`)
|
||||
- [x] 2.6 Write `tests/setup/polyfills.ts` and `README.md` skeleton
|
||||
- [x] 2.7 Confirm tier purity: zero `node:*`, `bun:*`, `window.*` imports anywhere under `src/`
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [x] 3.1 `src/error-codes/error-codes.ts` — `defineCodes("BUNDLE_URI", [...])` with 9 codes; `BundleUriError extends SuaveplanError` carrying `uri`/`scheme`/`packageSlug`/`relPath`/`rootKind` discriminants
|
||||
- [x] 3.2 `src/types/plugin-options.types.ts` — `BundleUriPluginOptions` interface (no inline shapes per §11)
|
||||
- [x] 3.3 `src/types/plugin-options.schemas.ts` — `BundleUriPluginOptionsSchema` (Zod): slug regex, rootUrl normalisation, optional `cache`/`fetch`/`logger`/`clock`
|
||||
- [x] 3.4 `src/parse/parse.ts` — `parse(uri, options)`, `build(key, meta?)`, traversal-prevention (raw + post-decode + URL-resolve check)
|
||||
- [x] 3.5 `src/abort/abort.ts` — pre-aborted short-circuit helper raising `BUNDLE_URI.ABORTED`; AbortError → BundleUriError conversion preserving `cause`
|
||||
- [x] 3.6 `src/range/range.ts` — `Range` header builder + 206/200/416 disambiguation + offset/length validation
|
||||
- [x] 3.7 `src/cache/cache.ts` — in-memory LRU keyed by URI, byte-bounded, default-off
|
||||
- [x] 3.8 `src/streaming/streaming.ts` — `createReadStream` forwarding `Response.body` with one-chunk fallback when body is null
|
||||
- [x] 3.9 `src/storage-shim/storage-shim.ts` — `StorageLike` adapter wrapping `FetchLike`: `get`, `exists` (HEAD), `getMetadata` (HEAD), `getPartial`, `createReadStream`; every write/list/delete/clear throws `READ_ONLY`
|
||||
- [x] 3.10 `src/otel/otel.ts` — tracer + meter wiring; `recordOp(op, rootKind, outcome, ms, bytes?)`; query-string stripping on `bundle.rel_path` attribute; 256-char truncation
|
||||
- [x] 3.11 `src/factory/factory.ts` — `bundleUriPlugin(options)` returning the `UriPlugin` (scheme `"bundle"`, parse, build, adapter = StorageShim)
|
||||
- [x] 3.12 `src/index.ts` — explicit named re-exports per §6a (no `export *`)
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [x] 4.1 `src/error-codes/error-codes.test.ts` — every `BUNDLE_URI.*` code reachable; registry exhaustion
|
||||
- [x] 4.2 `src/factory/factory.test.ts` — option validation, slug regex, rootUrl trailing-slash enforcement, default `fetch`
|
||||
- [x] 4.3 `src/parse/parse.test.ts` — accept well-formed URIs; reject scheme mismatch (INVALID_URI), empty rel-path (INVALID_URI), slug mismatch (PACKAGE_MISMATCH), raw `..` (PATH_TRAVERSAL), `%2E%2E` decoded (PATH_TRAVERSAL), URL-resolve escape (PATH_TRAVERSAL); `build` round-trips
|
||||
- [x] 4.4 `src/storage-shim/storage-shim.test.ts` — real `file://` temp-dir round-trip via `fetch`; real `node:http` localhost fixture round-trip; HEAD exists/metadata; every write/list/delete/clear throws `READ_ONLY`
|
||||
- [x] 4.5 `src/range/range.test.ts` — 206 verbatim against HTTP fixture; 200 fallback increments `range_fallback_total`; 416 → `RANGE_INVALID`; offset/length validation → `RANGE_INVALID`
|
||||
- [x] 4.6 `src/streaming/streaming.test.ts` — `createReadStream` forwards `Response.body`; one-chunk fallback when body is null
|
||||
- [x] 4.7 `src/abort/abort.test.ts` — pre-aborted signal → `BUNDLE_URI.ABORTED` (no fetch); mid-flight abort propagates `AbortError` on `cause`
|
||||
- [x] 4.8 `src/cache/cache.test.ts` — off by default; on, evicts oldest at byte ceiling; hit returns same bytes without re-fetch
|
||||
- [x] 4.9 `src/otel/otel.test.ts` — duration histogram + counters via in-memory `MeterProvider`; span attributes via in-memory `SpanExporter`; query string stripped from `bundle.rel_path`
|
||||
- [x] 4.10 `src/conformance/conformance.test.ts` — `runUriPluginConformance` passes against `file://` temp-dir root AND `http://localhost:<port>/` real fixture
|
||||
- [x] 4.11 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
- [x] 4.12 No imports from `vitest` or `bun:test` in test files — all via `@suaveplan/testing/runner` (rule 11)
|
||||
- [x] 4.13 No `vi.mock` / `jest.mock` — real `node:http` localhost fixture + real temp-dir `file://` resolution only
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [x] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, Purpose / Features / Basic + Advanced Examples / API Reference / Implementation Notes)
|
||||
- [x] 5.2 `README.md` at package root: description, install, quick-start, `## Modules` linking every co-located `.md`
|
||||
- [x] 5.3 README documents the per-package registration recipe (`new URL("./", import.meta.url)`)
|
||||
- [x] 5.4 README documents the bundler-flatten composition pattern (Vite / esbuild / webpack `?url` import)
|
||||
|
||||
## 6. Gates
|
||||
|
||||
- [x] 6.1 `bun run check` (biome write+unsafe)
|
||||
- [x] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-uri-plugin-bundle`
|
||||
- [x] 6.3 `bunx turbo test:coverage --filter=@suaveplan/storage-uri-plugin-bundle` — 100/100/100/100 istanbul
|
||||
- [x] 6.4 `bun scripts/lint/check-test-runner-imports.ts` (rule 11)
|
||||
- [x] 6.5 Tier-purity grep — `grep -rnE "from ['\"](node:|bun:|process)['\"]" packages/storage/storage-uri-plugin-bundle/src/` returns empty
|
||||
- [x] 6.6 Banned-pattern scan returns empty (`@deprecated`, `TODO`, `FIXME`, `ts-ignore`, `ts-expect-error`, `.skip`, `.only`, `any`, `console.log`)
|
||||
- [x] 6.7 Barrel-shape scan (§6a) — no `export *` in any feature `index.ts`
|
||||
- [x] 6.8 Missing-`.md` scan returns empty
|
||||
- [x] 6.9 Bare-version scan on `package.json` returns empty
|
||||
- [x] 6.10 Subpath-export count = feature module count (9)
|
||||
- [x] 6.11 `bun scripts/openspec-conformance.ts @suaveplan/storage-uri-plugin-bundle`
|
||||
- [x] 6.12 Mitata benchmarks present at `src/__benchmarks__/bundle-uri.bench.ts` with baseline in README
|
||||
|
||||
## 7. Manifest & root docs
|
||||
|
||||
- [x] 7.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`
|
||||
- [x] 7.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml`
|
||||
- [x] 7.3 Update `STATUS.md` (genesis-original — append to "Completed" section with the package-summary line)
|
||||
- [x] 7.4 Update `SUMMARY.md` with the per-package paragraph reflecting the shipped surface
|
||||
- [x] 7.5 `bunx markdownlint-cli2 --fix` on touched files
|
||||
- [x] 7.6 `bunx markdownlint-cli2` (no --fix) exits 0
|
||||
|
||||
## 8. Archival
|
||||
|
||||
- [x] 8.1 Fold `specs/storage-uri-plugin-bundle/spec.md` into `openspec/specs/storage-uri-plugin-bundle/spec.md`
|
||||
- [x] 8.2 Move `openspec/changes/add-storage-uri-plugin-bundle/` → `openspec/archive/<yyyy-mm-dd>-add-storage-uri-plugin-bundle/`
|
||||
|
||||
## 9. Numbered Requirement / Scenario checklist (§0c step 6 gate)
|
||||
|
||||
Implementation is NOT complete until every box below is checked. Each item maps 1:1 to a
|
||||
`### Requirement` / `#### Scenario` in `specs/storage-uri-plugin-bundle/spec.md`.
|
||||
|
||||
- [x] R1 Plugin factory shape and registration
|
||||
- [x] R1.S1 factory returns a `UriPlugin` with `scheme: "bundle"`
|
||||
- [x] R1.S2 factory validates options via Zod; invalid slug → throws
|
||||
- [x] R1.S3 rootUrl without trailing slash is normalised (or rejected) deterministically
|
||||
- [x] R2 `bundle://` URI grammar enforced by `parse`
|
||||
- [x] R2.S1 well-formed URI parses to `{ key, meta: { packageSlug } }`
|
||||
- [x] R2.S2 scheme mismatch → `INVALID_URI`
|
||||
- [x] R2.S3 empty rel-path → `INVALID_URI`
|
||||
- [x] R2.S4 missing authority → `INVALID_URI`
|
||||
- [x] R3 Per-package slug enforcement
|
||||
- [x] R3.S1 URI slug ≠ options.packageSlug → `PACKAGE_MISMATCH` carrying `actual`
|
||||
- [x] R4 Traversal prevention (raw + decoded + URL-resolve)
|
||||
- [x] R4.S1 raw `..` segment → `PATH_TRAVERSAL`
|
||||
- [x] R4.S2 pct-encoded `%2E%2E` after decode → `PATH_TRAVERSAL`
|
||||
- [x] R4.S3 URL-resolve outside rootUrl.pathname → `PATH_TRAVERSAL`
|
||||
- [x] R5 Read-only enforcement on every write surface
|
||||
- [x] R5.S1 `set` throws `READ_ONLY` with `op:"set"`
|
||||
- [x] R5.S2 `setPartial` throws `READ_ONLY`
|
||||
- [x] R5.S3 `delete` throws `READ_ONLY`
|
||||
- [x] R5.S4 `clear` throws `READ_ONLY`
|
||||
- [x] R5.S5 `list` throws `READ_ONLY`
|
||||
- [x] R6 GET resolves through injected `FetchLike`
|
||||
- [x] R6.S1 HTTP root: bytes returned verbatim
|
||||
- [x] R6.S2 `file://` root: bytes returned verbatim
|
||||
- [x] R6.S3 `404` → `NOT_FOUND`
|
||||
- [x] R6.S4 non-2xx non-404 → `FETCH_FAILED` with `httpStatus`
|
||||
- [x] R7 HEAD-backed `exists` and `getMetadata`
|
||||
- [x] R7.S1 `exists` returns `true` on `2xx`, `false` on `404`/`410`
|
||||
- [x] R7.S2 `getMetadata` parses `content-length`, `content-type`, `etag`, `last-modified`
|
||||
- [x] R8 Range reads
|
||||
- [x] R8.S1 HTTP root 206 path returns slice
|
||||
- [x] R8.S2 HTTP root 200 path slices client-side AND increments `range_fallback_total`
|
||||
- [x] R8.S3 416 → `RANGE_INVALID`
|
||||
- [x] R8.S4 invalid offset/length → `RANGE_INVALID` (no fetch)
|
||||
- [x] R8.S5 `data:`/`blob:` always slow-pathed, counter incremented
|
||||
- [x] R9 Streaming reads
|
||||
- [x] R9.S1 `createReadStream` forwards `Response.body` when present
|
||||
- [x] R9.S2 one-chunk fallback when `Response.body` is null
|
||||
- [x] R10 AbortSignal propagation
|
||||
- [x] R10.S1 pre-aborted signal → `ABORTED` without invoking fetch
|
||||
- [x] R10.S2 mid-flight abort propagates underlying `AbortError` on `cause`
|
||||
- [x] R11 Optional in-memory LRU cache
|
||||
- [x] R11.S1 cache off by default; counters stay at 0
|
||||
- [x] R11.S2 cache on; eviction respects `maxBytes`
|
||||
- [x] R11.S3 hit returns same bytes without invoking fetch
|
||||
- [x] R12 OTel instrumentation
|
||||
- [x] R12.S1 `op_duration_ms` recorded on success
|
||||
- [x] R12.S2 `errors_total{code}` recorded on failure
|
||||
- [x] R12.S3 query string stripped from `bundle.rel_path` attribute
|
||||
- [x] R12.S4 `bundle.root_kind` classified correctly per resolution-matrix §2
|
||||
- [x] R13 Conformance harness pass
|
||||
- [x] R13.S1 `runUriPluginConformance` passes against `file://` root
|
||||
- [x] R13.S2 `runUriPluginConformance` passes against `http://localhost:<port>/` root
|
||||
- [x] R14 Error-code registry exhaustion
|
||||
- [x] R14.S1 every `BUNDLE_URI.*` code is reachable from a real call site
|
||||
Reference in New Issue
Block a user