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

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, 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"