15 KiB
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. (Seeopenspec/AGENTS.mdSection 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.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/"