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

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. (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"


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
  • 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<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 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 <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 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/<feature>/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/"