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 concreteit(...)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, andsrc/loader/loader.tsare 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 emitscacheHit/cacheMiss/loadevents 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
installevent is dispatched on the shim afterrunPackedResourceServiceWorker(shim)is called - THEN the shim records that
skipWaiting()was invoked exactly once
Scenario: fetch handler intercepts a packed URL
- WHEN a
FetchEventfor a URL matching/\.packed$/is dispatched and the loader returns unpacked bytes - THEN the event's
respondWithis called with aResponsecarrying the unpacked bytes plus anX-Unpacked: trueheader, and a copy is written to the shim'sCache
Scenario: fetch handler passes through non-packed URLs
- WHEN a
FetchEventfor a URL that does not match a packed pattern is dispatched - THEN
respondWithis 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
verifyKeyand the response signature verifies against the manifest bytes - THEN the loader proceeds to
Unpacker.unpackand returns the Blob URL
Scenario: invalid signature aborts before unpack
- WHEN the supplied signature does not verify
- THEN the loader throws
PackerBrowserErrorwith codePACKER_BROWSER.ARCHIVE_SIGNATURE_INVALID,Unpacker.unpackis 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 showpacked_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 showpacked_resource_decode_msrecorded 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-browserentry is loaded - THEN no
PackedResourceManagerinstance exists in module scope (verified by introspecting the module's exported keys)
Scenario: /singleton subpath returns a stable shared instance
- WHEN
@suaveplan/packer-browser/singletonis imported twice - THEN both imports return the same
packedResourceManagerreference
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 PackerBrowserErrorand 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 withsignal.aborted === true - THEN the call rejects with
PackerBrowserError(PACKER_BROWSER.ABORTED)and nofetchis issued
Scenario: signal fires after fetch but before unpack
- WHEN the loader has fetched bytes and the signal fires before
Unpacker.unpackruns - 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/**/*.tsforresolvePackerBrowserMessage(..., "<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
cachedAtis1_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 forDate\.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 injectedfetchrejects twice then resolves - THEN the loader returns successfully and the injected
fetchwas called three times
Scenario: default is a single attempt with no retries
- WHEN
retryConfigis absent andfetchrejects 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 noflags - THEN the manager's resolved flag provider is a
MemoryProvidercarrying no flag definitions
Scenario: caller-supplied provider is preserved
- WHEN
createPackedResourceManager({ flags: customProvider })is constructed - THEN
manager.getFlagProvider()returns the samecustomProviderreference
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, anddist/utils/index.jsall exist
Scenario: package.json sideEffects is false
- WHEN
package.jsonis 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 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-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
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"
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.maxSizeis updated to1000after the initial estimate resolves
Scenario: missing estimate API falls back to the static ceiling
- WHEN the sizer is constructed with neither a
storageEstimateinjection nor a globalnavigator.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, thenaborter.abort()is called - THEN subsequent ticks of the recurring updater never invoke
storageEstimateagain
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()is3
Scenario: flag-gated off skips construction
- WHEN the manager is constructed with
adaptiveCache: { enabled: false } - THEN no sizer is created and
cache.maxSizeremains 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"
Requirement: PACKER-BROWSER-18 — <link rel="preload"> generation on SW install
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
Scenario: injection appends link elements to document head
- WHEN
injectPreloadHints(document, hints)is invoked under happy-dom with two hints - THEN
document.head.querySelectorAll('link[rel="preload"]').lengthis2
Scenario: each emitted hint increments fetchTotal
- WHEN
generatePreloadHints(manifest, { observability })emits two hints - THEN the observability bundle's
fetchTotalcounter 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"andallowedOrigins: ["https://cdn.example.com"] - THEN the underlying container's
registeris 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
nulland no counters are incremented
Scenario: present API delegates to BackgroundFetchManager.fetch
- WHEN the helper is called with a shim
BackgroundFetchManagerwhosefetchresolves - THEN the shim's
fetchis 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 whosematchAllreturns one record - THEN the configured cache contains the record's response and
prefetchSuccessTotalis incremented exactly once
Scenario: failure handler increments the failure counter
- WHEN
handleBackgroundFetchFailure(event, { observability })is invoked - THEN
prefetchFailureTotalis 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
cacheHitevents - THEN the overlay's event-ring snapshot contains exactly 50 entries, all of type
cacheHit
Scenario: dispose removes the element and unsubscribes
- WHEN
mountreturns a disposer and the disposer is invoked - THEN the overlay's element is no longer attached to
parent, and subsequent manageremitcalls leave the snapshot unchanged
Scenario: no-document environment returns a safe disposer
- WHEN
mountis called against an overlay constructed withoutdocument - 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 aftermount+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.exportsis inspected - THEN
./preload,./prefetch,./diagnosticsare present and eachimport.defaultvalue points at./dist/<feature>/index.js
Scenario: subpaths preserve tree-shake invariant
- WHEN
package.jsonis 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/"