# Capability Spec Delta: packer-browser (enhance-packer-browser-streaming) **Package**: `@suaveplan/packer-browser` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. Cluster A (PACKER-BROWSER-1..12) is preserved verbatim in `openspec/specs/packer-browser/spec.md`; this delta adds PACKER-BROWSER-13..16 as `## ADDED Requirements`. ## ADDED Requirements ### Requirement: PACKER-BROWSER-13 — Progressive streaming unpack The package SHALL expose `PackedResourceLoader.loadStream(inputs, opts?)` and `PackedResourceManager.loadStream(inputs, opts?)` that return an `AsyncIterable` yielding each decoded packed entry as soon as its bytes are fully decoded — subsequent entries MUST NOT block already-decoded consumers. The streaming API MUST honour an injected `AbortSignal` at every yield point: a pre-aborted signal MUST cause the iterator to throw `PackerBrowserError(PACKER_BROWSER.ABORTED)` before any IO runs; a signal that fires mid-stream MUST terminate the iterator cleanly on the next yield. The streaming path MUST coexist with the existing batch `load` path — it is additive, not a replacement. #### Scenario: each entry is emitted as soon as it's decoded - **WHEN** `loader.loadStream([a, b, c])` is consumed and entry `b` resolves before `a` - **THEN** the iterator emits `b` first (order-of-completion), then `a`, then `c` #### Scenario: pre-aborted signal short-circuits the stream - **WHEN** `loader.loadStream(inputs, { signal })` is invoked with `signal.aborted === true` - **THEN** the iterator throws `PackerBrowserError(PACKER_BROWSER.ABORTED)` on the first `next()` and no `fetch` is issued #### Scenario: mid-stream abort terminates cleanly - **WHEN** the signal fires after the first entry is yielded but before the second - **THEN** the iterator throws `PackerBrowserError(PACKER_BROWSER.ABORTED)` on the next `next()` call and no further entries are decoded #### Scenario: manager streams entries through to the cache - **WHEN** `manager.loadStream(inputs)` yields a `LoadedResource` - **THEN** the resource is inserted into the cache before the iterator advances to the next entry **Verified by:** `src/loader/loader-stream.test.ts::"emits each entry as soon as its bytes are decoded, in order of completion"` AND `src/loader/loader-stream.test.ts::"rejects with ABORTED when the signal is pre-aborted before any fetch"` AND `src/loader/loader-stream.test.ts::"terminates cleanly when the signal fires mid-stream"` AND `src/manager/manager.test.ts::"loadStream writes each decoded entry into the cache as it arrives"` --- ### Requirement: PACKER-BROWSER-14 — Range-request Service Worker support The package SHALL expose `serveRangeResponse(request, body, opts?)` at `src/range-response/range-response.ts` returning a `Response` that honours RFC 7233 §2.1 single-range semantics: a valid single `bytes=-` range whose body length meets the configured `rangeThreshold` MUST yield a `206 Partial Content` carrying `Content-Range: bytes -/`, `Accept-Ranges: bytes`, and `Content-Length: ` headers. A multi-range request (e.g. `bytes=0-9,20-29`) MUST yield `416 Range Not Satisfiable`. A malformed range header MUST yield `416`. An `If-Range` header carrying an ETag MUST yield `206` only when the etag matches the body's etag; on mismatch the helper MUST yield `412 Precondition Failed`. The Service Worker `fetch` handler MUST invoke `serveRangeResponse` for packed-URL requests carrying a `Range` header when the cached body size exceeds `RunServiceWorkerOptions.rangeThreshold` (default `262144` bytes = 256 KiB); below the threshold the SW MUST return the full 200 response as in Cluster A. #### Scenario: valid single range above threshold yields 206 - **WHEN** `serveRangeResponse` is called with a request carrying `Range: bytes=0-9`, a 512 KiB body, and `rangeThreshold: 1024` - **THEN** the response is `206`, `Content-Range: bytes 0-9/524288`, `Accept-Ranges: bytes`, `Content-Length: 10`, body length 10 #### Scenario: body below threshold returns the full 200 - **WHEN** the body length is below `rangeThreshold` - **THEN** the response is `200` with the full body and `Accept-Ranges: bytes` still present #### Scenario: multi-range request yields 416 - **WHEN** the request carries `Range: bytes=0-9,20-29` - **THEN** the response is `416 Range Not Satisfiable` #### Scenario: If-Range etag mismatch yields 412 - **WHEN** the request carries `If-Range: "v2"` and the cached body's etag is `"v1"` - **THEN** the response is `412 Precondition Failed` #### Scenario: SW fetch handler routes ranged requests through the helper - **WHEN** a packed-URL fetch event carries a `Range` header and the cached body exceeds `rangeThreshold` - **THEN** the SW responds with `206` **Verified by:** `src/range-response/range-response.test.ts::"yields 206 with Content-Range, Accept-Ranges, and Content-Length on a valid single range above threshold"` AND `src/range-response/range-response.test.ts::"returns the full 200 when the body is below rangeThreshold"` AND `src/range-response/range-response.test.ts::"yields 416 on a multi-range request"` AND `src/range-response/range-response.test.ts::"yields 412 on If-Range etag mismatch"` AND `src/range-response/range-response.test.ts::"yields 416 on a malformed Range header"` AND `src/service-worker/service-worker.test.ts::"fetch handler serves a 206 partial response for a ranged packed request above rangeThreshold"` --- ### Requirement: PACKER-BROWSER-15 — Encrypted archives with pluggable keyring The package SHALL define a `KeyringProvider` contract with `resolveKey(keyId: string, signal?: AbortSignal): Promise` (throwing `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` when the keyId is not resolvable) and ship two implementations: `emptyKeyring()` (rejects every keyId — the default so unencrypted archives keep working) and `MemoryKeyring` (constructed from a `Record`). The encrypted archive wire format MUST carry the stable magic prefix `SP-ENC1\x01` followed by a 4-byte big-endian header length, a UTF-8 JSON header `{ "alg": "AES-256-GCM", "keyId": string, "nonce": , "aad"?: }`, and the AES-256-GCM ciphertext+tag. The loader MUST detect the magic prefix on every fetched body and, when a `keyring` is supplied, resolve the key, AES-GCM decrypt the ciphertext, and pass the resulting plaintext through the standard verify+unpack pipeline. A missing key MUST surface `KEY_UNAVAILABLE`; a tampered ciphertext (GCM tag mismatch) MUST surface `LOAD_FAILED`. The default unencrypted path MUST continue to work unchanged when no encryption prefix is present. #### Scenario: empty keyring rejects every keyId - **WHEN** `emptyKeyring().resolveKey("k1")` is awaited - **THEN** the promise rejects with `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` #### Scenario: memory keyring returns its registered keys - **WHEN** `new MemoryKeyring({ k1: bytesA, k2: bytesB, k3: bytesC }).resolveKey("k2")` is awaited - **THEN** the promise resolves with `bytesB` #### Scenario: loader decrypts an encrypted archive via the keyring - **WHEN** the loader fetches a body carrying the `SP-ENC1` prefix and is configured with a keyring that resolves the header's `keyId` - **THEN** the loader AES-GCM decrypts, unpacks the plaintext, and returns a `LoadedResource` (no error surfaced) #### Scenario: missing key surfaces KEY_UNAVAILABLE - **WHEN** the loader fetches an encrypted body whose keyId is not in the configured keyring - **THEN** the loader throws `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` and `Unpacker.unpack` is not called #### Scenario: tampered ciphertext surfaces LOAD_FAILED - **WHEN** the loader fetches an encrypted body whose ciphertext byte 0 has been flipped - **THEN** the loader throws `PackerBrowserError(PACKER_BROWSER.LOAD_FAILED)` whose `cause` is the underlying AES-GCM tag-mismatch error **Verified by:** `src/keyring/keyring.test.ts::"emptyKeyring rejects every keyId with KEY_UNAVAILABLE"` AND `src/keyring/keyring.test.ts::"MemoryKeyring resolves three fixture keys and rejects an unknown id with KEY_UNAVAILABLE"` AND `src/encrypted-archive/encrypted-archive.test.ts::"round-trips plaintext through AES-256-GCM with the SP-ENC1 wire format"` AND `src/encrypted-archive/encrypted-archive.test.ts::"rejects a tampered ciphertext with a GCM tag-mismatch error"` AND `src/loader/loader.test.ts::"loader decrypts an SP-ENC1 archive via the injected keyring before unpacking"` AND `src/loader/loader.test.ts::"loader throws KEY_UNAVAILABLE when the keyring cannot resolve the keyId"` AND `src/error-codes.test.ts::"KEY_UNAVAILABLE is referenced by a production path"` --- ### Requirement: PACKER-BROWSER-16 — Content-addressed versioned archive URLs The package SHALL expose `computeArchiveContentHash(entries, formatVersion)` that returns a BLAKE3 Merkle root over canonical entry digests with the single-byte `formatVersion` appended to the seed material. The resulting hash MUST change when any entry changes OR when the formatVersion byte changes. The package SHALL expose `buildContentAddressedUrl(prefix, hash, ext?)` returning `/.` (default `ext = "bundle.asset"` so the canonical URL is `/packed/.bundle.asset`). The Service Worker SHALL accept `RunServiceWorkerOptions.contentAddressed` (default `true`) and `RunServiceWorkerOptions.legacyUrlPattern` (optional `RegExp`); when `contentAddressed` is on, the SW's `activate` handler MUST invoke `cleanupOutdatedCaches(scope, prefix, keep)` which deletes every cache whose name starts with `prefix` and is not present in `keep` — so a new manifest hash invalidates old cache entries on next install. When `legacyUrlPattern` is supplied, the `fetch` handler MUST treat matching URLs as packed (parity with `/packed/` from Cluster A) regardless of the content-addressed flag. The `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` flag MUST be registered against the manager's injected `FlagProvider` so adopters can flip the URL form at runtime without an API change. #### Scenario: content hash is deterministic for the same entries - **WHEN** `computeArchiveContentHash` is called twice with the same entries and the same formatVersion - **THEN** both calls return byte-identical 32-byte hashes #### Scenario: content hash changes with formatVersion - **WHEN** the same entries are hashed once with `formatVersion=1` and once with `formatVersion=2` - **THEN** the two hashes differ #### Scenario: URL builder produces the canonical packed path - **WHEN** `buildContentAddressedUrl("/packed", hashHex)` is called - **THEN** the result is `/packed/.bundle.asset` #### Scenario: cleanupOutdatedCaches deletes outdated entries on activate - **WHEN** the SW `activate` handler runs with `contentAddressed: true` and a current cache name list `keep = ["packed-resources-"]`, and the scope already has `packed-resources-` cached - **THEN** `packed-resources-` is deleted and `packed-resources-` is preserved #### Scenario: legacy URL pattern still resolves when supplied - **WHEN** the SW is configured with `legacyUrlPattern: /\/packed\/[a-z]+\.bundle\.asset$/` and a fetch event matching that pattern arrives - **THEN** the SW intercepts the request (treats it as packed) **Verified by:** `src/content-address/content-address.test.ts::"computeArchiveContentHash is deterministic across calls with identical entries"` AND `src/content-address/content-address.test.ts::"computeArchiveContentHash differs when formatVersion changes"` AND `src/content-address/content-address.test.ts::"buildContentAddressedUrl emits the canonical /packed/.bundle.asset form"` AND `src/content-address/content-address.test.ts::"cleanupOutdatedCaches deletes caches not in the keep list and preserves caches in the keep list"` AND `src/service-worker/service-worker.test.ts::"activate handler invokes cleanupOutdatedCaches when contentAddressed is on"` AND `src/service-worker/service-worker.test.ts::"fetch handler treats legacyUrlPattern matches as packed when supplied"`