# 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`, 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.` 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..` 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(..., "")` 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"`