Files
suaveplan/openspec/specs/packer-browser/spec.md
2026-05-27 23:29:53 +01:00

44 KiB

Capability Spec: packer-browser

Package: @suaveplan/packer-browser Status: SHIPPED (Cluster A — 2026-05-19-add-packer-browser-core)

Every requirement carries a **Verified by:** line referencing the concrete it(...) titles that pin it.

Requirements

Requirement: PACKER-BROWSER-1 — Manager split into lifecycle / cache / loader

The package SHALL split the upstream 318-LOC PackedResourceManager into three collaborator modules under the 300-LOC hard cap: src/manager/manager.ts (lifecycle façade, event emission, public surface), src/cache/cache.ts (LRU + ref-count + Blob-URL bookkeeping), and src/loader/loader.ts (fetch + signature verification + Unpacker + Blob construction). PackedResourceManager MUST compose a PackedResourceCache and a PackedResourceLoader; neither collaborator is allowed to import the other. Each of the three files MUST be ≤ 300 lines per CLAUDE.md §3.

Scenario: each module is under the 300-line cap

  • WHEN the package source tree is measured
  • THEN src/manager/manager.ts, src/cache/cache.ts, and src/loader/loader.ts are each ≤ 300 lines

Scenario: manager composes cache and loader

  • WHEN createPackedResourceManager() is constructed with no options
  • THEN manager.load(url) delegates to the loader for IO and to the cache for cache-key resolution / insertion / eviction, and emits cacheHit / cacheMiss / load events accordingly

Verified by: src/manager/manager.test.ts::"composes a cache and a loader and emits cacheHit on the second load" AND src/cache/cache.test.ts::"evicts the least-recently-used entry with refCount=0 when capacity is exceeded" AND src/loader/loader.test.ts::"fetches packed bytes and returns a Blob URL with the metadata-derived mime type"


Requirement: PACKER-BROWSER-2 — Service Worker is unit-tested via a real shim

The package SHALL ship a hand-rolled ServiceWorkerGlobalScope shim (createServiceWorkerShim) at src/service-worker/sw-shim.ts that implements addEventListener, dispatchEvent, skipWaiting, clients, caches (a CacheStorage + Cache real implementation, not a stub), FetchEvent, ExtendableEvent, and ExtendableMessageEvent. The shim MUST be a working in-memory implementation — no vi.mock, no vi.fn for the shim itself, no stubs. The service-worker module src/service-worker/service-worker.ts MUST register its handlers against the shim in tests; the same module is loaded verbatim by the production SW bundle. Tests MUST exercise: install (skipWaiting called), activate (old cache deletion + clients.claim called), fetch (packed-URL interception + cache write), fetch (non-packed URL passthrough), message (CACHE_CLEAR + CACHE_STATUS round trip).

Scenario: install handler calls skipWaiting

  • WHEN an install event is dispatched on the shim after runPackedResourceServiceWorker(shim) is called
  • THEN the shim records that skipWaiting() was invoked exactly once

Scenario: fetch handler intercepts a packed URL

  • WHEN a FetchEvent for a URL matching /\.packed$/ is dispatched and the loader returns unpacked bytes
  • THEN the event's respondWith is called with a Response carrying the unpacked bytes plus an X-Unpacked: true header, and a copy is written to the shim's Cache

Scenario: fetch handler passes through non-packed URLs

  • WHEN a FetchEvent for a URL that does not match a packed pattern is dispatched
  • THEN respondWith is NOT called and the event propagates normally

Verified by: src/service-worker/sw-shim.test.ts::"createServiceWorkerShim implements addEventListener, dispatchEvent, caches, and clients" AND src/service-worker/service-worker.test.ts::"install handler calls skipWaiting on the registered scope" AND src/service-worker/service-worker.test.ts::"fetch handler intercepts a packed URL and writes the unpacked response to the cache" AND src/service-worker/service-worker.test.ts::"fetch handler passes through URLs that do not match a packed pattern" AND src/service-worker/service-worker.test.ts::"message handler answers CACHE_CLEAR and CACHE_STATUS round trips"


Requirement: PACKER-BROWSER-3 — Archive signature verification (Ed25519) before unpack

The package SHALL expose verifyArchiveSignature(manifestBytes, signature, publicKey, opts?) at src/signature/signature.ts which calls @suaveplan/crypto's ed25519.verify(publicKey, signature, manifestBytes) and returns the boolean result. The loader MUST invoke verifyArchiveSignature before passing bytes to Unpacker when the caller supplies a verifyKey and the response carries an X-Archive-Signature header (hex-encoded 64-byte signature). A failed verification MUST throw PackerBrowserError(PACKER_BROWSER.ARCHIVE_SIGNATURE_INVALID) with the i18n key packer-browser.error.ARCHIVE_SIGNATURE_INVALID.verify-failed BEFORE any bytes are unpacked.

Scenario: valid signature passes through to unpack

  • WHEN the loader is supplied a verifyKey and the response signature verifies against the manifest bytes
  • THEN the loader proceeds to Unpacker.unpack and returns the Blob URL

Scenario: invalid signature aborts before unpack

  • WHEN the supplied signature does not verify
  • THEN the loader throws PackerBrowserError with code PACKER_BROWSER.ARCHIVE_SIGNATURE_INVALID, Unpacker.unpack is not called, and no Blob URL is created

Scenario: missing signature header is tolerated when verifyKey is absent

  • WHEN the caller does not pass verifyKey
  • THEN the loader skips signature verification and proceeds

Verified by: src/signature/signature.test.ts::"returns true when ed25519 verifies the signature" AND src/signature/signature.test.ts::"returns false when ed25519 rejects the signature" AND src/loader/loader.test.ts::"throws ARCHIVE_SIGNATURE_INVALID before calling Unpacker.unpack on a bad signature" AND src/loader/loader.test.ts::"skips signature verification when verifyKey is absent"


Requirement: PACKER-BROWSER-4 — OTel metrics from production paths

The package SHALL emit four OpenTelemetry instruments from production code paths via createPackerBrowserObservability(): packed_resource_fetch_total (Counter — incremented on every loader fetch attempt), packed_resource_cache_hits_total (Counter — incremented on every cache hit inside PackedResourceCache.get), packed_resource_cache_miss_total (Counter — incremented on every cache miss inside PackedResourceCache.get), and packed_resource_decode_ms (Histogram — observed once per successful loader decode with the elapsed ms between fetch start and Blob construction). The instruments MUST be asserted under a real in-memory MeterProvider from @opentelemetry/sdk-metrics — no vi.mock is permitted.

Scenario: cache hit increments the cache_hits counter

  • WHEN cache.get(key) returns a hit on the second call with the same key
  • THEN the asserted MeterProvider's collected metrics show packed_resource_cache_hits_total >= 1

Scenario: loader decode observes the histogram

  • WHEN the loader completes a successful unpack
  • THEN the asserted MeterProvider's collected metrics show packed_resource_decode_ms recorded at least one data point

Verified by: src/observability/observability.test.ts::"createPackerBrowserObservability instantiates the four named instruments on the supplied Meter" AND src/cache/cache.test.ts::"increments packed_resource_cache_hits_total on a hit and packed_resource_cache_miss_total on a miss" AND src/loader/loader.test.ts::"records packed_resource_decode_ms on a successful decode"


Requirement: PACKER-BROWSER-5 — Factory + /singleton subpath

The package SHALL expose createPackedResourceManager(options?: PackedResourceManagerOptions) from src/manager/manager.ts as the only construction surface in the default . export. The default export MUST NOT auto-construct a global instance at import time. The module-singleton packedResourceManager SHALL be hosted exclusively behind the /singleton subpath export (src/singleton.ts) so that consumers who import @suaveplan/packer-browser pay no import-time cost for the singleton.

Scenario: default export carries no auto-constructed singleton

  • WHEN the default @suaveplan/packer-browser entry is loaded
  • THEN no PackedResourceManager instance exists in module scope (verified by introspecting the module's exported keys)

Scenario: /singleton subpath returns a stable shared instance

  • WHEN @suaveplan/packer-browser/singleton is imported twice
  • THEN both imports return the same packedResourceManager reference

Verified by: src/manager/manager.test.ts::"createPackedResourceManager returns a fresh instance per call" AND src/singleton.test.ts::"packedResourceManager is the same reference across imports" AND src/singleton.test.ts::"the default index module does not export a packedResourceManager singleton"


Requirement: PACKER-BROWSER-6 — Error hierarchy via @suaveplan/error

The package SHALL define PACKER_BROWSER_CODES via defineCodes("PACKER_BROWSER", [...]) from @suaveplan/error carrying at least six codes: RESOURCE_NOT_FOUND, SW_REGISTRATION_FAILED, ARCHIVE_SIGNATURE_INVALID, UNSUPPORTED_RESOURCE_TYPE, ABORTED, LOAD_FAILED. Every error thrown by the package MUST be a PackerBrowserError (extending SuaveplanError) carrying one of these codes. Each code MUST be reachable from at least one production call site (no unused registry entries — CLAUDE.md §2c).

Scenario: every registered code is thrown by a production path

  • WHEN the production source tree is grepped for each PACKER_BROWSER_CODES.<CODE> symbol
  • THEN every code has at least one reference outside error-codes.ts, the i18n catalog, and test files

Scenario: thrown errors are PackerBrowserError instances

  • WHEN the loader rejects on a 404 packed URL
  • THEN the rejection is instanceof PackerBrowserError and its .code === PACKER_BROWSER.RESOURCE_NOT_FOUND

Verified by: src/error-codes.test.ts::"PACKER_BROWSER_CODES exposes the six stable codes" AND src/error-codes.test.ts::"every registered code is referenced by a production path" AND src/loader/loader.test.ts::"throws PackerBrowserError(RESOURCE_NOT_FOUND) on a 404 response"


Requirement: PACKER-BROWSER-7 — AbortSignal propagation on every async entrypoint

Every async public entrypoint SHALL accept an optional signal?: AbortSignal and check it at every yield point. The following surfaces MUST honour signal: PackedResourceManager.load, PackedResourceManager.preload, PackedResourceLoader.load, registerPackedResourceServiceWorker, clearServiceWorkerCache, getServiceWorkerCacheStatus, the SW fetch event handler (composing caller's event.request.signal with an internal abort). A pre-aborted signal MUST cause the call to reject with PackerBrowserError(PACKER_BROWSER.ABORTED) BEFORE any IO runs.

Scenario: pre-aborted signal short-circuits manager.load

  • WHEN manager.load(url, { signal }) is called with signal.aborted === true
  • THEN the call rejects with PackerBrowserError(PACKER_BROWSER.ABORTED) and no fetch is issued

Scenario: signal fires after fetch but before unpack

  • WHEN the loader has fetched bytes and the signal fires before Unpacker.unpack runs
  • THEN the call rejects with PackerBrowserError(PACKER_BROWSER.ABORTED) and no Blob URL is created

Verified by: src/loader/loader.test.ts::"rejects with ABORTED when signal is pre-aborted before fetch" AND src/loader/loader.test.ts::"rejects with ABORTED when signal fires between fetch and unpack" AND src/manager/manager.test.ts::"propagates AbortSignal from manager.load through to the loader"


Requirement: PACKER-BROWSER-8 — i18n MessageResolver keys, no hard-coded English

The package SHALL define a PACKER_BROWSER_MESSAGES catalog of packer-browser.error.<CODE>.<scenario> keys mapped to English defaults and a defaultPackerBrowserResolver: MessageResolver (from @suaveplan/types). Every thrown error message MUST be obtained from resolvePackerBrowserMessage(resolver, key, params?) — no hard-coded English string literals at throw sites.

Scenario: every key referenced from src has a catalog entry

  • WHEN the test harness scans src/**/*.ts for resolvePackerBrowserMessage(..., "<key>") call sites
  • THEN every referenced key exists in PACKER_BROWSER_MESSAGES

Scenario: resolver interpolates {name} placeholders

  • WHEN defaultPackerBrowserResolver("packer-browser.error.RESOURCE_NOT_FOUND.http-status", { status: 404 }) is called
  • THEN the returned string contains "404"

Verified by: src/i18n/messages.test.ts::"PACKER_BROWSER_MESSAGES covers every key referenced from src" AND src/i18n/messages.test.ts::"defaultPackerBrowserResolver interpolates named placeholders" AND src/i18n/messages.test.ts::"resolvePackerBrowserMessage falls back to the bare key on a missing entry"


Requirement: PACKER-BROWSER-9 — Injected Clock / Rng, no direct Date.now/Math.random

The package SHALL accept clock?: Clock and rng?: Rng on every public option bundle that needs wall-clock or randomness, defaulting through resolveRuntime(opts) (mirrors @suaveplan/packer/runtime). Every production read of wall-clock time MUST go through the resolved Clock; no production file MAY call Date.now() or Math.random() directly.

Scenario: cache uses the injected Clock for entry timestamps

  • WHEN createPackedResourceManager({ clock: () => 1_700_000_000_000 }) is constructed and a resource is loaded
  • THEN the cached entry's cachedAt is 1_700_000_000_000

Scenario: no production file calls Date.now / Math.random directly

  • WHEN a recursive grep against src/**/*.ts (excluding *.test.ts, *.types.ts) is run for Date\.now\|Math\.random
  • THEN zero matches exist outside src/runtime/runtime.ts

Verified by: src/runtime/runtime.test.ts::"resolveRuntime threads through caller-supplied Clock and Rng" AND src/cache/cache.test.ts::"entry timestamps come from the injected Clock" AND src/runtime/runtime.test.ts::"no production file calls Date.now or Math.random outside runtime defaults"


Requirement: PACKER-BROWSER-10 — @suaveplan/retry on the IO source-pull path

The loader's fetch step SHALL route through @suaveplan/retry's withRetry. The retry policy MUST be configurable per call via PackedResourceLoaderOptions.retryConfig?: RetryConfig. When retryConfig is absent the loader MUST default to a single-attempt policy (maxAttempts: 1) so wire behaviour matches the direct-fetch upstream contract. The retry loop MUST be AbortSignal-aware: cancellation aborts the loop immediately and surfaces as the retry package's RetryAbortedError.

Scenario: failing fetch is retried up to maxAttempts

  • WHEN the loader is configured with retryConfig: { maxAttempts: 3, delay: createConstantBackoff({ delay: 0 }) } and the injected fetch rejects twice then resolves
  • THEN the loader returns successfully and the injected fetch was called three times

Scenario: default is a single attempt with no retries

  • WHEN retryConfig is absent and fetch rejects once
  • THEN the rejection surfaces unchanged on the first attempt

Verified by: src/loader/loader.test.ts::"retries fetch up to maxAttempts when retryConfig is supplied" AND src/loader/loader.test.ts::"defaults to a single fetch attempt when retryConfig is absent"


Requirement: PACKER-BROWSER-11 — @suaveplan/feature-flags provider injected

The package SHALL accept an optional flags?: FlagProvider (type from @suaveplan/feature-flags) on PackedResourceManagerOptions. When absent, a no-op MemoryProvider MUST be used. The provider slot exists to gate Cluster B / C features; Cluster A registers no live flags but MUST expose the slot so adopters can configure it without an API change later.

Scenario: default uses a no-op MemoryProvider

  • WHEN createPackedResourceManager() is constructed with no flags
  • THEN the manager's resolved flag provider is a MemoryProvider carrying no flag definitions

Scenario: caller-supplied provider is preserved

  • WHEN createPackedResourceManager({ flags: customProvider }) is constructed
  • THEN manager.getFlagProvider() returns the same customProvider reference

Verified by: src/flags/flags.test.ts::"resolveFlagsProvider returns a MemoryProvider when no provider is supplied" AND src/flags/flags.test.ts::"resolveFlagsProvider returns the caller-supplied provider unchanged" AND src/manager/manager.test.ts::"manager.getFlagProvider returns the configured provider"


Requirement: PACKER-BROWSER-12 — Subpath exports for the §3 minimum five

The package SHALL ship five subpath exports (plus ./package.json): ., ./manager, ./service-worker, ./utils, ./singleton. Each subpath MUST be tree-shakeable (sideEffects: false) and MUST resolve to a dedicated entrypoint in dist/. The default . re-exports the public surface; the subpaths expose surgical imports per the audit's §3.10 ask.

Scenario: every subpath resolves to a built artefact

  • WHEN the build runs (vite build)
  • THEN dist/index.js, dist/singleton.js, dist/manager/index.js, dist/service-worker/index.js, and dist/utils/index.js all exist

Scenario: package.json sideEffects is false

  • WHEN package.json is inspected
  • THEN sideEffects === false

Verified by: src/index.test.ts::"every subpath export resolves to a built dist artefact" AND src/index.test.ts::"package.json declares sideEffects: false"


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-encrypted.test.ts::"loader decrypts an SP-ENC1 archive via the injected keyring before unpacking" AND src/loader/loader-encrypted.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"


Requirement: PACKER-BROWSER-17 — Adaptive cache sizing from navigator.storage.estimate()

The package SHALL expose createAdaptiveCacheSizer(opts) at src/adaptive-cache/adaptive-cache.ts. The sizer SHALL, on construction and on a recurring interval (default 60_000 ms, driven by the injected Clock's elapsed sampling), invoke an injected storageEstimate function (defaults to () => navigator.storage.estimate() when present) and call cache.setMaxCacheSize(Math.max(1, Math.floor(quota * fraction))) with the configured fraction (default 0.25). The sizer MUST honour an injected AbortSignal: a pre-aborted signal causes the constructor to return a disposer without scheduling any work; aborting at any later point cancels the timer and rejects in-flight estimate awaits. When navigator.storage.estimate is unavailable AND no storageEstimate is injected, the sizer MUST fall back to the cache's static configured ceiling and emit no further updates. The PackedResourceCache SHALL expose setMaxCacheSize(n: number): void that updates the ceiling and runs evictLRU() until size() <= n. The manager SHALL accept PackedResourceManagerOptions.adaptiveCache: AdaptiveCacheOptions and SHALL register the packer-browser.cache.adaptive flag (default true) on the injected FlagProvider; when the flag is false the sizer MUST NOT be created.

Scenario: sizer applies the configured fraction of the estimated quota

  • WHEN createAdaptiveCacheSizer({ cache, fraction: 0.25, storageEstimate: async () => ({ quota: 4000, usage: 0 }) }) is invoked
  • THEN cache.maxSize is updated to 1000 after the initial estimate resolves

Scenario: missing estimate API falls back to the static ceiling

  • WHEN the sizer is constructed with neither a storageEstimate injection nor a global navigator.storage.estimate
  • THEN the cache ceiling is unchanged and the sizer's disposer() is a safe no-op

Scenario: AbortSignal cancellation terminates the recurring updater

  • WHEN the sizer is constructed with signal: aborter.signal, then aborter.abort() is called
  • THEN subsequent ticks of the recurring updater never invoke storageEstimate again

Scenario: setMaxCacheSize evicts excess entries

  • WHEN the cache contains 5 unpinned entries and cache.setMaxCacheSize(3) is called
  • THEN the two least-recently-cached entries are evicted (revoked) and cache.size() is 3

Scenario: flag-gated off skips construction

  • WHEN the manager is constructed with adaptiveCache: { enabled: false }
  • THEN no sizer is created and cache.maxSize remains the static configured ceiling

Verified by: src/adaptive-cache/adaptive-cache.test.ts::"applies the configured fraction of the estimated quota to the cache ceiling" AND src/adaptive-cache/adaptive-cache.test.ts::"falls back to the static ceiling when navigator.storage.estimate is unavailable" AND src/adaptive-cache/adaptive-cache.test.ts::"AbortSignal cancellation terminates the recurring updater" AND src/adaptive-cache/adaptive-cache.test.ts::"disposer returned from the sizer stops further estimate calls" AND src/cache/cache.test.ts::"setMaxCacheSize evicts unpinned entries until size meets the new ceiling" AND src/manager/manager.test.ts::"adaptiveCache.enabled=false skips sizer construction"


The package SHALL expose generatePreloadHints(manifest, opts?): readonly PreloadHintLink[] at src/preload/preload.ts returning a pure list of { url, as, crossOrigin? } records derived from the manifest's preload: true entries OR the top-N entries by priority (descending, ties broken by lexicographic url) when opts.topN is supplied. The package SHALL expose injectPreloadHints(doc, hints): readonly HTMLLinkElement[] which appends <link rel="preload"> elements to doc.head when doc carries a head; in non-DOM contexts the helper MUST return an empty array without throwing. Every emitted hint MUST count toward PackerBrowserCounters.fetchTotal so dashboards account for preload traffic. The helpers MUST be re-exported from the new /preload subpath AND the existing root . entrypoint.

Scenario: hints derived from preload: true manifest entries

  • WHEN generatePreloadHints({ entries: [{ url: "/a", preload: true, as: "image" }, { url: "/b", preload: false, as: "font" }] }) is called
  • THEN the result contains exactly one hint with { url: "/a", as: "image" }

Scenario: top-N by priority overrides the preload flag

  • WHEN generatePreloadHints({ entries: [{ url: "/a", priority: 1 }, { url: "/b", priority: 5 }, { url: "/c", priority: 3 }] }, { topN: 2 }) is called
  • THEN the result is [{ url: "/b", ... }, { url: "/c", ... }] in that order

Scenario: injectPreloadHints is feature-detected

  • WHEN injectPreloadHints(undefined, hints) is invoked
  • THEN the call returns [] without throwing
  • WHEN injectPreloadHints(document, hints) is invoked under happy-dom with two hints
  • THEN document.head.querySelectorAll('link[rel="preload"]').length is 2

Scenario: each emitted hint increments fetchTotal

  • WHEN generatePreloadHints(manifest, { observability }) emits two hints
  • THEN the observability bundle's fetchTotal counter has been incremented twice

Verified by: src/preload/preload.test.ts::"derives hints from manifest entries marked preload: true" AND src/preload/preload.test.ts::"top-N selection orders by descending priority" AND src/preload/preload.test.ts::"injectPreloadHints returns an empty array when the document is undefined" AND src/preload/preload.test.ts::"injectPreloadHints appends one link element per hint to document.head" AND src/preload/preload.test.ts::"emits one fetchTotal counter increment per generated hint when observability is supplied"


Requirement: PACKER-BROWSER-19 — Multi-origin Service Worker helper

The package SHALL expose registerCrossOriginPackedResourceServiceWorker(opts) at src/utils/cross-origin.ts accepting { swUrl: string, scope: string, allowedOrigins: readonly string[], container?: ServiceWorkerContainerLike, messageResolver? }. The helper SHALL parse swUrl and reject with PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN) BEFORE any register() call when the parsed origin is not present in allowedOrigins. On success the helper SHALL delegate to registerPackedResourceServiceWorker and then invoke container.getRegistrations() (mirroring clients.matchAll({ includeUncontrolled: true }) semantics by listing every registration regardless of activation state) so callers can verify the cross-origin SW is reachable. The new PACKER_BROWSER.UNAUTHORIZED_ORIGIN code SHALL be added to PACKER_BROWSER_CODES and reachable from this helper's production path.

Scenario: SW URL whose origin is not in the allowlist is rejected

  • WHEN registerCrossOriginPackedResourceServiceWorker({ swUrl: "https://attacker.com/sw.js", scope: "/", allowedOrigins: ["https://cdn.example.com"] }) is called
  • THEN the promise rejects with PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN)

Scenario: SW URL whose origin is in the allowlist registers

  • WHEN the helper is called with swUrl: "https://cdn.example.com/sw.js" and allowedOrigins: ["https://cdn.example.com"]
  • THEN the underlying container's register is invoked exactly once with ("https://cdn.example.com/sw.js", { scope })

Scenario: registration list is queried after success

  • WHEN registration succeeds against the allowlisted origin
  • THEN the container's getRegistrations() is awaited and its returned array is exposed on the helper's resolved value

Verified by: src/utils/cross-origin.test.ts::"rejects with UNAUTHORIZED_ORIGIN when swUrl origin is not allowlisted" AND src/utils/cross-origin.test.ts::"delegates to registerPackedResourceServiceWorker when origin is allowlisted" AND src/utils/cross-origin.test.ts::"returns the container's registration list after successful registration" AND src/error-codes.test.ts::"UNAUTHORIZED_ORIGIN is referenced by a production path"


Requirement: PACKER-BROWSER-20 — BackgroundFetch API integration

The package SHALL expose prefetchPackedArchive(archiveUrl, opts): Promise<BackgroundFetchRegistrationLike | null> at src/prefetch/prefetch.ts. When opts.backgroundFetch (or navigator.serviceWorker.ready.backgroundFetch resolved from a real ServiceWorkerRegistration in production) is available, the helper SHALL invoke backgroundFetch.fetch(id, [archiveUrl], opts.options) and resolve with the registration. When the API is missing, the helper SHALL resolve with null. The package SHALL expose handleBackgroundFetchSuccess(event, deps) for the SW's backgroundfetchsuccess listener: on success, the helper SHALL drain the event's records into the configured cache (default packed-resources-v1) and increment PackerBrowserCounters.prefetchSuccessTotal. On failure or backgroundfetchfailure invocations the helper SHALL increment PackerBrowserCounters.prefetchFailureTotal. The two new counters (packed_resource_prefetch_success_total and packed_resource_prefetch_failure_total) MUST be created on the observability bundle and asserted under a real in-memory MeterProvider.

Scenario: missing API returns null

  • WHEN prefetchPackedArchive("/x.packed", { backgroundFetch: undefined }) is called
  • THEN the promise resolves to null and no counters are incremented

Scenario: present API delegates to BackgroundFetchManager.fetch

  • WHEN the helper is called with a shim BackgroundFetchManager whose fetch resolves
  • THEN the shim's fetch is invoked exactly once with the configured id, the archive URL array, and the options bundle, and the helper's resolved value is the shim's returned registration

Scenario: success handler drains records into the cache and increments the success counter

  • WHEN handleBackgroundFetchSuccess(event, { caches, cacheName: "test", observability }) is invoked against a shim event whose matchAll returns one record
  • THEN the configured cache contains the record's response and prefetchSuccessTotal is incremented exactly once

Scenario: failure handler increments the failure counter

  • WHEN handleBackgroundFetchFailure(event, { observability }) is invoked
  • THEN prefetchFailureTotal is incremented exactly once

Verified by: src/prefetch/prefetch.test.ts::"resolves to null when BackgroundFetchManager is unavailable" AND src/prefetch/prefetch.test.ts::"delegates to BackgroundFetchManager.fetch when the API is available" AND src/prefetch/prefetch.test.ts::"success handler drains records into the cache and increments prefetchSuccessTotal" AND src/prefetch/prefetch.test.ts::"failure handler increments prefetchFailureTotal" AND src/observability/observability.test.ts::"prefetchSuccessTotal and prefetchFailureTotal are emitted under a real MeterProvider"


Requirement: PACKER-BROWSER-21 — Diagnostics dev overlay

The package SHALL expose createDiagnosticsOverlay(manager, opts?): { mount(parent?: ParentNode): () => void } at src/diagnostics/diagnostics.ts. The factory SHALL subscribe to the manager's TinyEmitter events (load, error, cacheHit, cacheMiss, cacheEvict), maintain an in-memory ring of the last 50 events, and render a single <div> whose child nodes report cache size, hit-ratio, miss-ratio, in-flight count, and the event ring. The factory MUST NOT mutate the manager (subscribe-only). The mount method MUST return a disposer that removes the rendered element and unsubscribes every listener. When document is undefined, mount MUST return a no-op disposer without throwing.

Scenario: overlay renders cache stats from the manager

  • WHEN the manager's cache contains two entries and the overlay is mounted onto a happy-dom <body>
  • THEN the rendered element's text contains the substring cache.size=2

Scenario: event ring records the last 50 events

  • WHEN the manager emits 60 cacheHit events
  • THEN the overlay's event-ring snapshot contains exactly 50 entries, all of type cacheHit

Scenario: dispose removes the element and unsubscribes

  • WHEN mount returns a disposer and the disposer is invoked
  • THEN the overlay's element is no longer attached to parent, and subsequent manager emit calls leave the snapshot unchanged

Scenario: no-document environment returns a safe disposer

  • WHEN mount is called against an overlay constructed without document
  • THEN the disposer is a callable no-op and no errors are thrown

Scenario: overlay does not mutate the manager

  • WHEN the overlay is mounted and manager.getCacheStats() is recorded before and after mount + dispose
  • THEN both snapshots are structurally equal

Verified by: src/diagnostics/diagnostics.test.ts::"renders cache.size derived from the manager" AND src/diagnostics/diagnostics.test.ts::"event ring retains only the last 50 events" AND src/diagnostics/diagnostics.test.ts::"disposer removes the element and unsubscribes every listener" AND src/diagnostics/diagnostics.test.ts::"returns a safe no-op disposer when document is absent" AND src/diagnostics/diagnostics.test.ts::"mount and dispose do not mutate the manager's cache state"


Requirement: PACKER-BROWSER-22 — Final subpath split + Playwright E2E coverage

The package SHALL ship three additional subpath exports on top of PACKER-BROWSER-12's five: ./preload, ./prefetch, ./diagnostics. Each new subpath MUST be tree-shakeable ("sideEffects": false) and MUST resolve to a built artefact under dist/. The package SHALL ship Playwright spec files under tests/e2e/ covering the following scenarios: SW install + packed URL intercept, range-request 206 response, encrypted archive end-to-end, content- addressed URL invalidation on SW activate, preload-hint DOM injection, and diagnostics overlay mount/unmount. The Playwright config SHALL validate without browser binaries installed; running the suite is gated on bunx playwright install chromium being executed in the runtime environment.

Scenario: every new subpath resolves to a built dist artefact

  • WHEN the build runs and package.json.exports is inspected
  • THEN ./preload, ./prefetch, ./diagnostics are present and each import.default value points at ./dist/<feature>/index.js

Scenario: subpaths preserve tree-shake invariant

  • WHEN package.json is inspected
  • THEN sideEffects === false

Scenario: Playwright config is valid and the E2E spec files exist

  • WHEN the test harness scans tests/e2e/
  • THEN files exist for SW intercept, range 206, encrypted archive, content-addressed invalidation, preload injection, and diagnostics mount/unmount

Verified by: src/index.test.ts::"every subpath export resolves to a built dist artefact (cluster C)" AND src/index.test.ts::"package.json declares sideEffects: false (cluster C)" AND src/index.test.ts::"every Playwright E2E spec file exists under tests/e2e/"