# Capability Spec Delta: packer-browser (enhance-packer-browser-platform) **Package**: `@suaveplan/packer-browser` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring > time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: PACKER-BROWSER-17 — Adaptive cache sizing from `navigator.storage.estimate()` The package SHALL expose `createAdaptiveCacheSizer(opts)` at `src/adaptive-cache/adaptive-cache.ts`. The sizer SHALL, on construction and on a recurring interval (default 60_000 ms, driven by the injected `Clock`'s elapsed sampling), invoke an injected `storageEstimate` function (defaults to `() => navigator.storage.estimate()` when present) and call `cache.setMaxCacheSize(Math.max(1, Math.floor(quota * fraction)))` with the configured `fraction` (default `0.25`). The sizer MUST honour an injected `AbortSignal`: a pre-aborted signal causes the constructor to return a disposer without scheduling any work; aborting at any later point cancels the timer and rejects in-flight estimate awaits. When `navigator.storage.estimate` is unavailable AND no `storageEstimate` is injected, the sizer MUST fall back to the cache's static configured ceiling and emit no further updates. The `PackedResourceCache` SHALL expose `setMaxCacheSize(n: number): void` that updates the ceiling and runs `evictLRU()` until `size() <= n`. The manager SHALL accept `PackedResourceManagerOptions.adaptiveCache: AdaptiveCacheOptions` and SHALL register the `packer-browser.cache.adaptive` flag (default `true`) on the injected `FlagProvider`; when the flag is `false` the sizer MUST NOT be created. #### Scenario: sizer applies the configured fraction of the estimated quota - **WHEN** `createAdaptiveCacheSizer({ cache, fraction: 0.25, storageEstimate: async () => ({ quota: 4000, usage: 0 }) })` is invoked - **THEN** `cache.maxSize` is updated to `1000` after the initial estimate resolves #### Scenario: missing estimate API falls back to the static ceiling - **WHEN** the sizer is constructed with neither a `storageEstimate` injection nor a global `navigator.storage.estimate` - **THEN** the cache ceiling is unchanged and the sizer's `disposer()` is a safe no-op #### Scenario: AbortSignal cancellation terminates the recurring updater - **WHEN** the sizer is constructed with `signal: aborter.signal`, then `aborter.abort()` is called - **THEN** subsequent ticks of the recurring updater never invoke `storageEstimate` again #### Scenario: setMaxCacheSize evicts excess entries - **WHEN** the cache contains 5 unpinned entries and `cache.setMaxCacheSize(3)` is called - **THEN** the two least-recently-cached entries are evicted (revoked) and `cache.size()` is `3` #### Scenario: flag-gated off skips construction - **WHEN** the manager is constructed with `adaptiveCache: { enabled: false }` - **THEN** no sizer is created and `cache.maxSize` remains the static configured ceiling **Verified by:** `src/adaptive-cache/adaptive-cache.test.ts::"applies the configured fraction of the estimated quota to the cache ceiling"` AND `src/adaptive-cache/adaptive-cache.test.ts::"falls back to the static ceiling when navigator.storage.estimate is unavailable"` AND `src/adaptive-cache/adaptive-cache.test.ts::"AbortSignal cancellation terminates the recurring updater"` AND `src/adaptive-cache/adaptive-cache.test.ts::"disposer returned from the sizer stops further estimate calls"` AND `src/cache/cache.test.ts::"setMaxCacheSize evicts unpinned entries until size meets the new ceiling"` AND `src/manager/manager.test.ts::"adaptiveCache.enabled=false skips sizer construction"` --- ### Requirement: PACKER-BROWSER-18 — `` 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 `` 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"]').length` is `2` #### Scenario: each emitted hint increments fetchTotal - **WHEN** `generatePreloadHints(manifest, { observability })` emits two hints - **THEN** the observability bundle's `fetchTotal` counter has been incremented twice **Verified by:** `src/preload/preload.test.ts::"derives hints from manifest entries marked preload: true"` AND `src/preload/preload.test.ts::"top-N selection orders by descending priority"` AND `src/preload/preload.test.ts::"injectPreloadHints returns an empty array when the document is undefined"` AND `src/preload/preload.test.ts::"injectPreloadHints appends one link element per hint to document.head"` AND `src/preload/preload.test.ts::"emits one fetchTotal counter increment per generated hint when observability is supplied"` --- ### Requirement: PACKER-BROWSER-19 — Multi-origin Service Worker helper The package SHALL expose `registerCrossOriginPackedResourceServiceWorker(opts)` at `src/utils/cross-origin.ts` accepting `{ swUrl: string, scope: string, allowedOrigins: readonly string[], container?: ServiceWorkerContainerLike, messageResolver? }`. The helper SHALL parse `swUrl` and reject with `PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN)` BEFORE any `register()` call when the parsed origin is not present in `allowedOrigins`. On success the helper SHALL delegate to `registerPackedResourceServiceWorker` and then invoke `container.getRegistrations()` (mirroring `clients.matchAll({ includeUncontrolled: true })` semantics by listing every registration regardless of activation state) so callers can verify the cross-origin SW is reachable. The new `PACKER_BROWSER.UNAUTHORIZED_ORIGIN` code SHALL be added to `PACKER_BROWSER_CODES` and reachable from this helper's production path. #### Scenario: SW URL whose origin is not in the allowlist is rejected - **WHEN** `registerCrossOriginPackedResourceServiceWorker({ swUrl: "https://attacker.com/sw.js", scope: "/", allowedOrigins: ["https://cdn.example.com"] })` is called - **THEN** the promise rejects with `PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN)` #### Scenario: SW URL whose origin is in the allowlist registers - **WHEN** the helper is called with `swUrl: "https://cdn.example.com/sw.js"` and `allowedOrigins: ["https://cdn.example.com"]` - **THEN** the underlying container's `register` is invoked exactly once with `("https://cdn.example.com/sw.js", { scope })` #### Scenario: registration list is queried after success - **WHEN** registration succeeds against the allowlisted origin - **THEN** the container's `getRegistrations()` is awaited and its returned array is exposed on the helper's resolved value **Verified by:** `src/utils/cross-origin.test.ts::"rejects with UNAUTHORIZED_ORIGIN when swUrl origin is not allowlisted"` AND `src/utils/cross-origin.test.ts::"delegates to registerPackedResourceServiceWorker when origin is allowlisted"` AND `src/utils/cross-origin.test.ts::"returns the container's registration list after successful registration"` AND `src/error-codes.test.ts::"UNAUTHORIZED_ORIGIN is referenced by a production path"` --- ### Requirement: PACKER-BROWSER-20 — BackgroundFetch API integration The package SHALL expose `prefetchPackedArchive(archiveUrl, opts): Promise` at `src/prefetch/prefetch.ts`. When `opts.backgroundFetch` (or `navigator.serviceWorker.ready.backgroundFetch` resolved from a real `ServiceWorkerRegistration` in production) is available, the helper SHALL invoke `backgroundFetch.fetch(id, [archiveUrl], opts.options)` and resolve with the registration. When the API is missing, the helper SHALL resolve with `null`. The package SHALL expose `handleBackgroundFetchSuccess(event, deps)` for the SW's `backgroundfetchsuccess` listener: on success, the helper SHALL drain the event's records into the configured cache (default `packed-resources-v1`) and increment `PackerBrowserCounters.prefetchSuccessTotal`. On failure or `backgroundfetchfailure` invocations the helper SHALL increment `PackerBrowserCounters.prefetchFailureTotal`. The two new counters (`packed_resource_prefetch_success_total` and `packed_resource_prefetch_failure_total`) MUST be created on the observability bundle and asserted under a real in-memory `MeterProvider`. #### Scenario: missing API returns null - **WHEN** `prefetchPackedArchive("/x.packed", { backgroundFetch: undefined })` is called - **THEN** the promise resolves to `null` and no counters are incremented #### Scenario: present API delegates to BackgroundFetchManager.fetch - **WHEN** the helper is called with a shim `BackgroundFetchManager` whose `fetch` resolves - **THEN** the shim's `fetch` is invoked exactly once with the configured id, the archive URL array, and the options bundle, and the helper's resolved value is the shim's returned registration #### Scenario: success handler drains records into the cache and increments the success counter - **WHEN** `handleBackgroundFetchSuccess(event, { caches, cacheName: "test", observability })` is invoked against a shim event whose `matchAll` returns one record - **THEN** the configured cache contains the record's response and `prefetchSuccessTotal` is incremented exactly once #### Scenario: failure handler increments the failure counter - **WHEN** `handleBackgroundFetchFailure(event, { observability })` is invoked - **THEN** `prefetchFailureTotal` is incremented exactly once **Verified by:** `src/prefetch/prefetch.test.ts::"resolves to null when BackgroundFetchManager is unavailable"` AND `src/prefetch/prefetch.test.ts::"delegates to BackgroundFetchManager.fetch when the API is available"` AND `src/prefetch/prefetch.test.ts::"success handler drains records into the cache and increments prefetchSuccessTotal"` AND `src/prefetch/prefetch.test.ts::"failure handler increments prefetchFailureTotal"` AND `src/observability/observability.test.ts::"prefetchSuccessTotal and prefetchFailureTotal are emitted under a real MeterProvider"` --- ### Requirement: PACKER-BROWSER-21 — Diagnostics dev overlay The package SHALL expose `createDiagnosticsOverlay(manager, opts?): { mount(parent?: ParentNode): () => void }` at `src/diagnostics/diagnostics.ts`. The factory SHALL subscribe to the manager's `TinyEmitter` events (`load`, `error`, `cacheHit`, `cacheMiss`, `cacheEvict`), maintain an in-memory ring of the last 50 events, and render a single `
` 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 `` - **THEN** the rendered element's text contains the substring `cache.size=2` #### Scenario: event ring records the last 50 events - **WHEN** the manager emits 60 `cacheHit` events - **THEN** the overlay's event-ring snapshot contains exactly 50 entries, all of type `cacheHit` #### Scenario: dispose removes the element and unsubscribes - **WHEN** `mount` returns a disposer and the disposer is invoked - **THEN** the overlay's element is no longer attached to `parent`, and subsequent manager `emit` calls leave the snapshot unchanged #### Scenario: no-document environment returns a safe disposer - **WHEN** `mount` is called against an overlay constructed without `document` - **THEN** the disposer is a callable no-op and no errors are thrown #### Scenario: overlay does not mutate the manager - **WHEN** the overlay is mounted and `manager.getCacheStats()` is recorded before and after `mount` + `dispose` - **THEN** both snapshots are structurally equal **Verified by:** `src/diagnostics/diagnostics.test.ts::"renders cache.size derived from the manager"` AND `src/diagnostics/diagnostics.test.ts::"event ring retains only the last 50 events"` AND `src/diagnostics/diagnostics.test.ts::"disposer removes the element and unsubscribes every listener"` AND `src/diagnostics/diagnostics.test.ts::"returns a safe no-op disposer when document is absent"` AND `src/diagnostics/diagnostics.test.ts::"mount and dispose do not mutate the manager's cache state"` --- ### Requirement: PACKER-BROWSER-22 — Final subpath split + Playwright E2E coverage The package SHALL ship three additional subpath exports on top of PACKER-BROWSER-12's five: `./preload`, `./prefetch`, `./diagnostics`. Each new subpath MUST be tree-shakeable (`"sideEffects": false`) and MUST resolve to a built artefact under `dist/`. The package SHALL ship Playwright spec files under `tests/e2e/` covering the following scenarios: SW install + packed URL intercept, range-request 206 response, encrypted archive end-to-end, content- addressed URL invalidation on SW activate, preload-hint DOM injection, and diagnostics overlay mount/unmount. The Playwright config SHALL validate without browser binaries installed; running the suite is gated on `bunx playwright install chromium` being executed in the runtime environment. #### Scenario: every new subpath resolves to a built dist artefact - **WHEN** the build runs and `package.json.exports` is inspected - **THEN** `./preload`, `./prefetch`, `./diagnostics` are present and each `import.default` value points at `./dist//index.js` #### Scenario: subpaths preserve tree-shake invariant - **WHEN** `package.json` is inspected - **THEN** `sideEffects === false` #### Scenario: Playwright config is valid and the E2E spec files exist - **WHEN** the test harness scans `tests/e2e/` - **THEN** files exist for SW intercept, range 206, encrypted archive, content-addressed invalidation, preload injection, and diagnostics mount/unmount **Verified by:** `src/index.test.ts::"every subpath export resolves to a built dist artefact (cluster C)"` AND `src/index.test.ts::"package.json declares sideEffects: false (cluster C)"` AND `src/index.test.ts::"every Playwright E2E spec file exists under tests/e2e/"`