18 KiB
Capability Spec Delta: packer-browser (add-packer-browser-core)
Package: @suaveplan/packer-browser
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED 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"