12 KiB
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 inopenspec/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<LoadedResource> 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 entrybresolves beforea - THEN the iterator emits
bfirst (order-of-completion), thena, thenc
Scenario: pre-aborted signal short-circuits the stream
- WHEN
loader.loadStream(inputs, { signal })is invoked withsignal.aborted === true - THEN the iterator throws
PackerBrowserError(PACKER_BROWSER.ABORTED)on the firstnext()and nofetchis 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 nextnext()call and no further entries are decoded
Scenario: manager streams entries through to the cache
- WHEN
manager.loadStream(inputs)yields aLoadedResource - 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=<start>-<end> range whose body length meets the configured
rangeThreshold MUST yield a 206 Partial Content carrying
Content-Range: bytes <start>-<end>/<total>, Accept-Ranges: bytes,
and Content-Length: <chunk-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
serveRangeResponseis called with a request carryingRange: bytes=0-9, a 512 KiB body, andrangeThreshold: 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
200with the full body andAccept-Ranges: bytesstill 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
Rangeheader and the cached body exceedsrangeThreshold - 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<Uint8Array>
(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<string, Uint8Array>). 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": <base64>, "aad"?: <base64> },
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-ENC1prefix and is configured with a keyring that resolves the header'skeyId - 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)andUnpacker.unpackis 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)whosecauseis 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
<prefix>/<hash>.<ext> (default ext = "bundle.asset" so the
canonical URL is /packed/<hash>.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/<name> 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
computeArchiveContentHashis 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=1and once withformatVersion=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/<hashHex>.bundle.asset
Scenario: cleanupOutdatedCaches deletes outdated entries on activate
- WHEN the SW
activatehandler runs withcontentAddressed: trueand a current cache name listkeep = ["packed-resources-<newHash>"], and the scope already haspacked-resources-<oldHash>cached - THEN
packed-resources-<oldHash>is deleted andpacked-resources-<newHash>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/<hash>.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"