Files
2026-05-27 23:29:53 +01:00

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 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<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 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=<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 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<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-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 <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 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/<hashHex>.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-<newHash>"], and the scope already has packed-resources-<oldHash> cached
  • THEN packed-resources-<oldHash> is deleted and packed-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"