Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
# `adaptive-cache` — `navigator.storage.estimate()`-driven cache sizing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createAdaptiveCacheSizer` periodically resizes a `PackedResourceCache`
from a `navigator.storage.estimate()`-style quota report so the LRU
ceiling tracks the actual storage the origin has available rather than a
static guess. The sizer is the production implementation of
PACKER-BROWSER-17 — adaptive cache sizing — and is what enables a
packer-browser deployment to ride the upstream quota without manually
re-tuning the cache on every device.
The module is **Clock-free**: it relies on the configured `intervalMs`
through an injectable timer (default `setInterval`), so deterministic
tests substitute a manual tick implementation rather than mocking time.
Cancellation is propagated through an `AbortSignal`; pre-aborted signals
short-circuit construction so the sizer never schedules work.
## Features
- Initial estimate fired synchronously on construction
- Recurring refresh on a configurable interval (default `60_000` ms)
- Pluggable `storageEstimate` injection (defaults to
`() => navigator.storage.estimate()` when available)
- Graceful no-op fallback when neither the injection nor the global API
is present
- `AbortSignal`-aware: aborting cancels the timer and stops further
estimate calls
- Disposer is idempotent and can be called from a manager's
shutdown path
## Usage: Basic
```ts
import { createAdaptiveCacheSizer } from "@suaveplan/packer-browser/adaptive-cache";
import { PackedResourceCache } from "@suaveplan/packer-browser";
const cache = new PackedResourceCache({ runtime, observability });
const sizer = createAdaptiveCacheSizer({ cache });
// On teardown:
sizer.dispose();
```
## Usage: Advanced
```ts
const controller = new AbortController();
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.1, // 10 % of quota
intervalMs: 30_000,
storageEstimate: async () => ({ quota: 8 * 1024 * 1024 * 1024 }),
signal: controller.signal,
});
await sizer.refresh(); // one-shot refresh
console.log(sizer.getLastApplied()); // 858_993_459
controller.abort(); // stops the recurring updater
```
## API Reference
- `createAdaptiveCacheSizer(opts: AdaptiveCacheSizerOptions): AdaptiveCacheSizer`
- `opts.cache` — `PackedResourceCache` to size. Required.
- `opts.fraction` — clamped to `(0, 1]`. Default `0.25`.
- `opts.intervalMs` — refresh interval. Default `60_000`.
- `opts.storageEstimate` — pluggable estimate function.
- `opts.signal` — cancellation `AbortSignal`.
- `opts.setIntervalImpl` — test-only timer injection.
- Returns: `{ dispose, refresh, getLastApplied }`.
- `AdaptiveCacheSizer.dispose()` — idempotent stop.
- `AdaptiveCacheSizer.refresh(): Promise<void>` — one-shot refresh.
- `AdaptiveCacheSizer.getLastApplied(): number | undefined` — most recent
ceiling applied, or `undefined` before the first refresh.
## Implementation Notes
- The initial estimate is fired with `void refresh()` so construction
is synchronous and the call returns the disposer immediately. Callers
who need deterministic timing can `await sizer.refresh()`.
- When `quota` is missing or non-positive the sizer is a defensive no-op
for that tick — the cache's previous ceiling is preserved.
- The default `setInterval` adapter calls `.unref()` when present so a
Node test process is not held open by the recurring updater.
- The sizer never reads the cache; it only writes via
`cache.setMaxCacheSize(n)`, which is itself a runtime mutator that
performs eviction in-place.

View File

@@ -0,0 +1,104 @@
# `cache`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cache.ts` implements `PackedResourceCache` — an LRU + ref-count cache
that owns the Blob-URL lifecycle for every unpacked resource the
`PackedResourceLoader` produces. It exists as a discrete collaborator
(rather than baked into the manager) so the manager file stays under
300 LOC and so the eviction policy is independently testable. The cache
also owns the OTel instrumentation for cache hits / misses.
## Features
- LRU eviction with refCount pinning: entries with `refCount > 0`
cannot be evicted; on overflow the least-recently-cached
`refCount === 0` entry is dropped.
- Blob-URL lifecycle: every eviction revokes the Blob URL through an
injectable `revokeObjectUrl` (defaults to `URL.revokeObjectURL`).
- OTel-instrumented `get`: every call records exactly one
`cacheHitsTotal` or `cacheMissTotal` increment.
- Clock-driven entry timestamps — wall-clock reads come from the
injected `Clock` so deterministic tests can pin entry ages.
- `cleanupExpired()` for periodic supervised eviction of stale
entries; `clear()` for hard reset (revokes every URL regardless of
refCount).
## Usage: Basic
```ts
import {
createPackerBrowserObservability,
PackedResourceCache,
resolveRuntime,
} from "@suaveplan/packer-browser";
const cache = new PackedResourceCache({
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
});
const blob = new Blob(["payload"]);
const url = URL.createObjectURL(blob);
cache.insert("key", blob, url, {
originalSize: 7,
compressedSize: 7,
encrypted: false,
timestamp: Date.now(),
checksum: "",
});
const hit = cache.get("key");
// hit.url === url; refCount === 2 (insert started at 1, get incremented)
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing";
import { PackedResourceCache } from "@suaveplan/packer-browser";
const clock = new FakeClock({ now: 0 });
const revoked: string[] = [];
const cache = new PackedResourceCache({
runtime: { clock: () => clock.now(), monotonicClock: () => 0n, rng: defaultRng },
observability,
maxCacheAge: 60_000,
revokeObjectUrl: (url) => revoked.push(url),
});
// later, advance time and run periodic cleanup
clock.tick(120_000);
const evicted = cache.cleanupExpired();
// evicted === 1; revoked === ["blob:..."]
```
## API Reference
- `new PackedResourceCache(opts: PackedResourceCacheOptions)`.
- `cache.get(key): CachedResource | undefined` — increments refCount
- refreshes timestamp on hit; records OTel counter on every call.
- `cache.insert(key, blob, url, metadata): CachedResource` — adds an
entry with refCount 1; evicts LRU on overflow.
- `cache.release(key): void` — decrement refCount.
- `cache.cleanupExpired(): number` — evict every expired
refCount-zero entry; returns count.
- `cache.clear(): void` — evict every entry (refCount ignored).
- `cache.getStats(): CacheStats` — snapshot summary.
- `cache.size(): number` / `cache.has(key): boolean` /
`cache.evictLRU(): string | undefined`.
## Implementation Notes
The cache deliberately does NOT trigger a background timer for stale
eviction. Periodic cleanup is the consumer's responsibility — the
manager invokes `cleanupExpired()` at a cadence the caller controls,
not on a hard-coded `setInterval`. This keeps the cache compatible
with environments where timers are unavailable (e.g. some Service
Worker contexts during install). The default `maxCacheSize` of 100
mirrors the upstream value; the upcoming Cluster B adaptive sizing
will override it based on `navigator.storage.estimate()`.

View File

@@ -0,0 +1,103 @@
# content-address
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Content-addressed URL builder and cache-invalidation helpers for packed
archives. Each archive's canonical URL is derived from a BLAKE3 Merkle
root over its entries plus the format-version byte, so any payload or
wire-format change produces a different URL and the Service Worker can
cleanly invalidate the prior version on next install. This module is
the foundation for the `/packed/<hash>.bundle.asset` URL form and the
SW `activate`-time cache pruning.
## Features
- `computeArchiveContentHash(entries, formatVersion)` — deterministic
32-byte BLAKE3 root that mixes the format-version byte into the leaf
set so a wire-format bump is reflected in the URL.
- `contentHashHex(hash)` — lowercase-hex serialiser used by URL builders
and cache-key emitters.
- `buildContentAddressedUrl(prefix, hash, ext?)` — emits the canonical
`<prefix>/<hash>.<ext>` URL (default ext `bundle.asset` matches the
Cluster A SW pattern).
- `cleanupOutdatedCaches(caches, prefix, keep)` — Service Worker helper
that deletes every cache name with a given prefix not in `keep`.
- `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` — stable feature-flag
string registered against the manager's `FlagProvider`.
## Usage: Basic Example
```ts
import {
buildContentAddressedUrl,
computeArchiveContentHash,
contentHashHex,
} from "@suaveplan/packer-browser";
const entries = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])];
const hash = computeArchiveContentHash(entries, /* formatVersion */ 1);
const url = buildContentAddressedUrl("/packed", contentHashHex(hash));
// → "/packed/<64-hex>.bundle.asset"
```
## Usage: Advanced Example
```ts
import {
cleanupOutdatedCaches,
runPackedResourceServiceWorker,
} from "@suaveplan/packer-browser";
// Inside the Service Worker bootstrap:
runPackedResourceServiceWorker(self as unknown as ServiceWorkerGlobalScope, {
cacheName: `packed-resources-${currentHash}`,
contentAddressed: true,
});
// `cleanupOutdatedCaches` is invoked by the SW's activate handler
// automatically when `contentAddressed` is on. You can also call it
// from a maintenance message handler:
self.addEventListener("message", async (ev) => {
if ((ev as MessageEvent).data?.type === "GC") {
const deleted = await cleanupOutdatedCaches(
self.caches,
"packed-resources-",
[`packed-resources-${currentHash}`]
);
console.info("Pruned", deleted);
}
});
```
## API Reference
- `computeArchiveContentHash(entries: ReadonlyArray<Uint8Array>, formatVersion: number): Uint8Array`
Throws `RangeError` if `formatVersion` is not an integer in [0, 255].
- `contentHashHex(hash: Uint8Array): string` Throws `RangeError` on
non-32-byte input.
- `buildContentAddressedUrl(prefix: string, hash: string, ext?: string): string`
- `cleanupOutdatedCaches(caches: CacheStorageLike, prefix: string, keep: ReadonlyArray<string>): Promise<string[]>`
Returns the list of cache names that were deleted.
- `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED: string` — `"packer-browser.url.content-addressed"`.
## Implementation Notes
- The format-version byte is appended as a synthetic trailing entry
rather than mixed via HKDF so the implementation stays simple and
every transformation is auditable via the existing
`entryDigestBlake3` + `merkleRootBlake3` primitives.
- `cleanupOutdatedCaches` accepts any object with `keys()` and
`delete()` methods; both the production `globalThis.caches` and the
in-memory `ShimCacheStorage` satisfy the contract, which keeps the SW
unit tests honest.
- `buildContentAddressedUrl` trims a trailing slash from the prefix so
callers can pass `/packed` or `/packed/` interchangeably.
- The Cluster A SW pattern `/\.packed$/` continues to match the
canonical content-addressed form; the Cluster B
`RunServiceWorkerOptions.legacyUrlPattern` slot accepts any other
RegExp callers need for backwards compatibility (e.g. legacy
named-archive URLs).

View File

@@ -0,0 +1,79 @@
# `diagnostics` — development-time overlay for the packed-resource manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDiagnosticsOverlay` produces a development-time overlay that
displays live cache statistics and a tail of recent events from a
`PackedResourceManager`. The overlay is subscribe-only — it never
mutates the manager — and renders a single `<div>` with inline styles
so it can be dropped onto any page without a stylesheet.
This module is PACKER-BROWSER-21's production surface. It is
feature-detected: when the runtime has no `document`, `mount` returns a
safe no-op disposer so the helper can be imported unconditionally from
universal code paths.
## Features
- Live cache size from `manager.getCache().size()`
- Hit / miss counters with a rolling hit-ratio
- Bounded event ring (default 50) over `load`, `error`, `cacheHit`,
`cacheMiss`, `cacheEvict`
- Subscribe-only — no manager state is mutated
- Disposer removes the element and unsubscribes every listener
- Mountable into a custom `ParentNode`
## Usage: Basic
```ts
import { createDiagnosticsOverlay } from "@suaveplan/packer-browser/diagnostics";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const manager = createPackedResourceManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount(); // attaches to document.body
// On teardown:
dispose();
```
## Usage: Advanced
```ts
const overlay = createDiagnosticsOverlay(manager, {
historyLimit: 200,
now: () => performance.now(),
document: shadowRoot.ownerDocument,
});
const dispose = overlay.mount(shadowRoot);
console.log(overlay.getSnapshot());
```
## API Reference
- `createDiagnosticsOverlay(manager, opts?): DiagnosticsOverlay`
- `opts.document?: Document` — DOM injection.
- `opts.historyLimit?: number` — ring size. Default `50`.
- `opts.now?: () => number` — clock for event timestamps. Default `Date.now`.
- `DiagnosticsOverlay.mount(parent?: ParentNode): () => void` — returns
a disposer that removes the element and unsubscribes.
- `DiagnosticsOverlay.getSnapshot(): DiagnosticsSnapshot` — read-only.
## Implementation Notes
- The overlay subscribes to the manager's `TinyEmitter` events
immediately on construction, BEFORE `mount` is called, so callers
who only want the snapshot (no DOM) can still observe events. The
disposer returned from `mount` is responsible for unsubscribing.
- The rendered element carries `data-packer-browser-diagnostics` so
tests can locate it without relying on inline class hooks.
- Hits and misses are counted on the overlay side (not from the
manager's OTel counters) so the helper does not require an
observability bundle.
- The overlay never calls `manager.load`, `manager.preload`, or
`manager.release`; the only manager surface it reads is `getCache()`
and the `on(...)` subscriber.

View File

@@ -0,0 +1,106 @@
# encrypted-archive
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wire format for AES-256-GCM-encrypted packed archives served alongside
unencrypted bundles. Adopts the stable magic prefix `SP-ENC1` followed
by a length-prefixed JSON header (`{ alg, keyId, nonce, aad? }`) and the
GCM ciphertext+tag. The format keeps the encrypted path opt-in: bodies
without the prefix flow through the loader unchanged, so consumers can
ship a mixed-CDN of encrypted and unencrypted bundles without forking
the loader.
## Features
- Stable 8-byte magic prefix (`53 50 2D 45 4E 43 31 01`) detected via a
cheap byte-equality check on the loader hot path.
- Self-describing header: `alg`, `keyId`, base64-encoded `nonce`, and an
optional `aad`. No fragile binary layout for the header — JSON, with
a hard 64 KiB cap to prevent malicious oversize headers from blowing
the buffer.
- AES-256-GCM via `@suaveplan/crypto`'s `aeadEncrypt`/`aeadDecrypt`. Tag
mismatch surfaces as `PackerBrowserError(LOAD_FAILED)` with the
underlying `CryptoError` attached as `cause` for forensic logging.
- Pure functions — no implicit RNG, no implicit key derivation. The
caller supplies key + nonce; this module just shapes the bytes.
## Usage: Basic Example
```ts
import {
decodeEncryptedArchiveHeader,
decryptEncryptedArchive,
encodeEncryptedArchive,
} from "@suaveplan/packer-browser";
const key = new Uint8Array(32).fill(0xaa);
const nonce = new Uint8Array(12).fill(0x01);
const wire = encodeEncryptedArchive(
new TextEncoder().encode("hello"),
"tenant-a",
key,
nonce
);
const decoded = decodeEncryptedArchiveHeader(wire);
const plaintext = decryptEncryptedArchive(decoded, key);
// → Uint8Array of "hello"
```
## Usage: Advanced Example
```ts
import {
decodeEncryptedArchiveHeader,
decryptEncryptedArchive,
hasEncryptedArchiveMagic,
MemoryKeyring,
} from "@suaveplan/packer-browser";
const keyring = new MemoryKeyring({
"tenant-a": new Uint8Array(32).fill(0xaa),
});
async function unwrap(fetched: Uint8Array): Promise<Uint8Array> {
if (!hasEncryptedArchiveMagic(fetched)) return fetched;
const decoded = decodeEncryptedArchiveHeader(fetched);
const key = await keyring.resolveKey(decoded.header.keyId);
return decryptEncryptedArchive(decoded, key);
}
```
## API Reference
- `SP_ENC1_MAGIC: Uint8Array` — the 8-byte magic prefix.
- `hasEncryptedArchiveMagic(bytes: Uint8Array): boolean` — detector.
- `encodeEncryptedArchive(plaintext, keyId, key, nonce, opts?): Uint8Array` —
encoder. `opts.aad?: Uint8Array` is bound into the GCM tag.
- `decodeEncryptedArchiveHeader(bytes: Uint8Array): DecodedEncryptedArchive` —
parses the header + slices the ciphertext. Throws `LOAD_FAILED` on a
malformed frame.
- `decryptEncryptedArchive(decoded, key): Uint8Array` — wraps
`aeadDecrypt`. Throws `LOAD_FAILED` on tag mismatch with `cause`
attached.
## Implementation Notes
- The header length is encoded as a 4-byte big-endian unsigned integer.
Big-endian is the on-wire convention used by `@suaveplan/packer`'s
manifest decoder; matching it here keeps cross-package readers
uniform.
- The `aad` field, when present, MUST cover the same bytes at encrypt
and decrypt time — typically a hash of the surrounding request URL or
manifest hash. The wire format does NOT bind `aad` automatically; the
caller decides whether to include it.
- A 64 KiB header cap protects against pathological adversarial inputs
that could trigger a large allocation before any AEAD work has run.
Realistic headers are 200–400 bytes.
- AES-256-GCM was chosen over XChaCha20-Poly1305 because the loader is
expected to run in browsers where AES-NI is hardware-accelerated and
Web Crypto can be plugged in via the `engine` option of
`aeadEncrypt`/`aeadDecrypt` for further performance.

View File

@@ -0,0 +1,89 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` is the single source of truth for every error class and
stable code emitted by `@suaveplan/packer-browser`. It registers the
`PACKER_BROWSER_CODES` constant via `defineCodes` from `@suaveplan/error`
so the wire-serialiser, OTel adapter, and i18n resolver in the broader
error stack pick up the codes automatically. Every production path that
throws routes through `PackerBrowserError` so consumers can classify
failures without string-matching message text.
## Features
- Six stable codes covering the Cluster A failure surface:
`RESOURCE_NOT_FOUND`, `SW_REGISTRATION_FAILED`,
`ARCHIVE_SIGNATURE_INVALID`, `UNSUPPORTED_RESOURCE_TYPE`, `ABORTED`,
`LOAD_FAILED`.
- `PackerBrowserError extends SuaveplanError<TCode>` with `.name`
branded `"PackerBrowserError"` so logger sinks render structured
output.
- `PACKER_BROWSER_ERROR_METADATA` table mapping every code to
`{ httpStatus, severity, retryable, description }` — consumed by
HTTP boundary middleware and retry policies.
## Usage: Basic
```ts
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "@suaveplan/packer-browser";
throw new PackerBrowserError(
PACKER_BROWSER_CODES.RESOURCE_NOT_FOUND,
"Packed resource fetch returned HTTP 404."
);
```
## Usage: Advanced
```ts
import {
PACKER_BROWSER_CODES,
PACKER_BROWSER_ERROR_METADATA,
PackerBrowserError,
} from "@suaveplan/packer-browser";
try {
await manager.load("/missing.packed");
} catch (err) {
if (err instanceof PackerBrowserError) {
const meta =
PACKER_BROWSER_ERROR_METADATA[
err.code as keyof typeof PACKER_BROWSER_ERROR_METADATA
];
if (meta.retryable) {
// schedule a retry
}
}
}
```
## API Reference
- `PACKER_BROWSER_CODES` — frozen record mapping bare code names
(e.g. `RESOURCE_NOT_FOUND`) to their fully-qualified form
(`"PACKER_BROWSER.RESOURCE_NOT_FOUND"`).
- `PackerBrowserErrorCode` — union of every value in
`PACKER_BROWSER_CODES`.
- `PACKER_BROWSER_ERROR_METADATA[code]` —
`{ httpStatus: number; severity: "info" | "warning" | "error"; retryable: boolean; description: string }`.
- `class PackerBrowserError<TCode> extends SuaveplanError<TCode>` —
constructor `(code, message, options?)`.
## Implementation Notes
The registry is intentionally exhaustive of the Cluster A surface, not
of the eventual public contract — Cluster B (encrypted-archive keyring)
and Cluster C (BackgroundFetch / range / multi-origin) will extend the
list. Every existing code is reachable from a production call site;
unused entries are banned by CLAUDE.md §2c and would fail the
conformance gate. The metadata `httpStatus` field is the value
to surface from any HTTP-boundary translator (e.g. a Hono middleware)
that re-renders a `PackerBrowserError` as an outbound response.

View File

@@ -0,0 +1,76 @@
# `flags`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`flags.ts` resolves the `@suaveplan/feature-flags` `FlagProvider` used
by `PackedResourceManager` to gate Cluster B / C features. Cluster A
registers no live flags, but the provider slot exists so adopters can
wire a `MemoryProvider` (or any other `FlagProvider`) without an API
churn later. The default is an empty `MemoryProvider` — every
`isEnabled` query falls through to the strategy default because no
`FlagDefinition` is registered.
## Features
- `resolveFlagsProvider(opts)` — single-call helper that returns
either the caller-supplied provider or a fresh empty
`MemoryProvider` from `@suaveplan/feature-flags`.
- Zero startup cost in the default path — no flag definitions are
registered, no IO happens, no `setInterval` is set.
- Composes cleanly with the rest of the manager's option bundle:
`PackedResourceManagerOptions.flags?: FlagProvider` flows straight
through.
## Usage: Basic
```ts
import { resolveFlagsProvider } from "@suaveplan/packer-browser";
const provider = resolveFlagsProvider();
const result = await provider.evaluate({ key: "anything" }, {});
// result.value === false (no flag defined)
```
## Usage: Advanced
```ts
import { MemoryProvider } from "@suaveplan/feature-flags";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const provider = new MemoryProvider([
{
key: "packer-browser.progressive-streaming",
type: "boolean",
defaultValue: false,
strategies: [],
},
]);
const manager = createPackedResourceManager({ flags: provider });
const sameProvider = manager.getFlagProvider();
// sameProvider === provider — caller-supplied instance is preserved
```
## API Reference
- `resolveFlagsProvider(opts?: FlagsOptions): FlagProvider` — returns
`opts.flags ?? new MemoryProvider([])`.
- `FlagsOptions` — `{ flags?: FlagProvider }`.
- Re-export: `FlagProvider` (alias of
`@suaveplan/feature-flags`'s).
## Implementation Notes
The slot is intentionally minimal in Cluster A. The reason it ships
now, even though no feature consumes it, is that adding a top-level
option to a manager constructor later forces every adopter to update
their type signatures — wiring it in once at first delivery means
Cluster B can flip `progressive-streaming` on with a one-line config
change. The default uses `MemoryProvider([])` rather than a null
provider so caller code can call `provider.evaluate(...)` without
guard checks: an empty `MemoryProvider` returns `defaultValue` for
every unknown flag.

View File

@@ -0,0 +1,91 @@
# `i18n/messages`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`messages.ts` is the English defaults catalog plus the resolver hook for
every user-visible error message thrown by `@suaveplan/packer-browser`.
It exists so that `throw` sites in `src/` never carry inline English
literals — instead they pass a `packer-browser.error.<CODE>.<scenario>`
key to `resolvePackerBrowserMessage`, which an application can override
by passing a custom `MessageResolver` (e.g. one backed by an i18n
catalogue) to any public option bundle.
## Features
- `PACKER_BROWSER_MESSAGES` — frozen catalog covering every key
referenced from production code; missing entries fall back to the
bare key (visible in test output, never silently absent).
- `defaultPackerBrowserResolver` — `MessageResolver` instance that
performs `{name}` placeholder interpolation from the resolver's
`params` argument.
- `resolvePackerBrowserMessage(resolver, key, params?)` — helper used
at every `throw` site so the resolver-vs-default branch lives in one
place.
- Placeholder syntax: `"{name}"` (not `"${name}"`) — biome's
`noTemplateCurlyInString` rule rejects the template-literal form
inside string literals.
## Usage: Basic
```ts
import { resolvePackerBrowserMessage } from "@suaveplan/packer-browser";
const msg = resolvePackerBrowserMessage(
undefined,
"packer-browser.error.RESOURCE_NOT_FOUND.http-status",
{ status: 404, url: "/missing.packed" }
);
// "Packed resource fetch returned HTTP 404 for /missing.packed."
```
## Usage: Advanced
```ts
import type { MessageResolver } from "@suaveplan/packer-browser";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const frenchResolver: MessageResolver = (key, params) => {
const fr: Record<string, string> = {
"packer-browser.error.RESOURCE_NOT_FOUND.http-status":
"Ressource introuvable ({url}) — HTTP {status}.",
};
let out = fr[key] ?? key;
if (params !== undefined) {
for (const [k, v] of Object.entries(params)) {
out = out.replaceAll(`{${k}}`, String(v));
}
}
return out;
};
const manager = createPackedResourceManager({
messageResolver: frenchResolver,
});
```
## API Reference
- `PACKER_BROWSER_MESSAGES: PackerBrowserMessageCatalog` — frozen
English defaults; readonly `[key: string]: string`.
- `defaultPackerBrowserResolver: MessageResolver` — looks up the key
in `PACKER_BROWSER_MESSAGES` and interpolates `{name}` from
`params`.
- `resolvePackerBrowserMessage(resolver, key, params?): string` —
routes through `resolver ?? defaultPackerBrowserResolver`.
- Re-exported types: `MessageResolver` (alias of
`@suaveplan/types`'s), `PackerBrowserMessageCatalog`.
## Implementation Notes
The catalog is sparse: only keys actually referenced from `src/`
appear. A conformance test scans `src/**/*.ts` for resolver call sites
and asserts every key has a catalog entry. Missing entries surface to
the user as the bare key, which is intentional — silent fallback to a
canned English string would mask a missing catalogue entry in
production. Consumers wiring a custom `MessageResolver` should
implement the same fallback (return the key verbatim when no
translation exists) so the contract round-trips cleanly.

View File

@@ -0,0 +1,98 @@
# keyring
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `KeyringProvider` contract for resolving symmetric AES-256-GCM
keys at decrypt time. The contract is intentionally narrow — one async
`resolveKey(keyId, signal?)` method — so production deployments can plug in
KMS, HSM, IndexedDB, or Web Crypto-backed implementations without
modifying the loader pipeline. This module also ships two concrete
implementations used in tests and small deployments: `emptyKeyring()` and
`MemoryKeyring`.
## Features
- Async `resolveKey(keyId, signal?)` contract with `AbortSignal`
propagation for HSM/KMS-backed implementations.
- `emptyKeyring()` — the safe default: every `resolveKey` call rejects
with `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` so encrypted
archives never silently pass through.
- `MemoryKeyring` — `Record<string, Uint8Array>`-backed implementation
for tests and single-tenant deployments. Validates that every key is
exactly 32 bytes at construction time.
- Errors are typed: missing keyId surfaces `KEY_UNAVAILABLE`; pre-aborted
signal surfaces `ABORTED`. Loader code can switch on the discriminated
code without parsing messages.
## Usage: Basic Example
```ts
import { MemoryKeyring, emptyKeyring } from "@suaveplan/packer-browser";
const keyring = new MemoryKeyring({
"tenant-a": new Uint8Array(32),
"tenant-b": new Uint8Array(32).fill(1),
});
const key = await keyring.resolveKey("tenant-a");
// → 32-byte Uint8Array
// The default no-op keyring rejects every keyId — used when callers
// expect only unencrypted archives.
const noKeyring = emptyKeyring();
```
## Usage: Advanced Example
```ts
import {
createPackedResourceManager,
MemoryKeyring,
} from "@suaveplan/packer-browser";
const manager = createPackedResourceManager({
fetch: globalThis.fetch,
});
const keyring = new MemoryKeyring({
"v1-2026": new Uint8Array(32).fill(7),
});
// Pass the keyring through the loader options on a per-load basis.
const url = await manager.load("/encrypted/asset.packed", {
// The manager forwards `keyring` to its internal loader; an
// `SP-ENC1`-prefixed body is decrypted before unpack.
// (Loader options carry the keyring slot — see loader.md.)
});
```
## API Reference
- `KeyringProvider` — interface with one method:
- `resolveKey(keyId: string, signal?: AbortSignal): Promise<Uint8Array>`
- `emptyKeyring(): KeyringProvider` — rejects every keyId with
`KEY_UNAVAILABLE`.
- `MemoryKeyring` — class implementing `KeyringProvider`.
- `new MemoryKeyring(entries: Record<string, Uint8Array>)` — every
value must be exactly 32 bytes.
- `resolveKey(keyId, signal?)` — returns the registered key or throws
`KEY_UNAVAILABLE`; honours `AbortSignal` (throws `ABORTED`).
- `size(): number` — number of registered keys.
## Implementation Notes
- The 32-byte length check at construction time guarantees the loader
never has to validate key length on the hot path. A wrong-length key
surfaces immediately as a constructor-time `PackerBrowserError` rather
than a downstream AES-GCM tag mismatch.
- `AbortSignal` is checked once on entry; long-running async resolvers
(network KMS calls) are responsible for honouring the signal between
awaits. The contract documents the requirement; enforcement is the
resolver's job.
- The contract intentionally accepts a `string` keyId — not a typed
union — so opaque tenant identifiers work without the keyring needing
to be type-parameterised.

View File

@@ -0,0 +1,80 @@
# loader-helpers
## Purpose
Small free helpers extracted from `loader.ts` so the class stays under
the 300-line hard cap. These functions carry no state and are exercised
indirectly through the loader's tests (every helper has at least one
loader-level scenario that pins its behaviour) plus the
`encrypted-archive` and `range-response` tests for their wire formats.
## Features
- `ARCHIVE_SIGNATURE_HEADER` — the canonical lowercase header name
(`x-archive-signature`) used for Ed25519 signature transport.
- `NO_RETRY` — the default single-attempt retry config used by the
loader when the caller does not supply one.
- `defaultFetch(input, init?)` — `globalThis.fetch` wrapper that's
injectable through `PackedResourceLoaderOptions.fetch`.
- `hexToBytes(hex)` — decode an `0x`-prefixable hex string into a
`Uint8Array`. Used for the signature header.
- `maybeDecryptArchive(bytes, keyring, signal, resolver)` — branch on
the `SP-ENC1` magic prefix: pass-through unencrypted bytes; decrypt
through the supplied keyring otherwise. Throws `KEY_UNAVAILABLE` when
a keyring is missing for an encrypted body.
## Usage: Basic Example
```ts
import { hexToBytes, defaultFetch } from "./loader-helpers.js";
const sig = hexToBytes("0xab12cd34");
const res = await defaultFetch("/asset.packed");
```
## Usage: Advanced Example
```ts
import { maybeDecryptArchive } from "./loader-helpers.js";
import { MemoryKeyring } from "../keyring/keyring.js";
const keyring = new MemoryKeyring({
"tenant-a": new Uint8Array(32).fill(7),
});
// Inside a loader pipeline, swap in the helper between fetch + unpack.
const fetched = new Uint8Array(await response.arrayBuffer());
const plain = await maybeDecryptArchive(
fetched,
keyring,
/* signal */ undefined,
/* resolver */ undefined
);
```
## API Reference
- `ARCHIVE_SIGNATURE_HEADER: "x-archive-signature"`.
- `NO_RETRY: { maxAttempts: 1; delay: () => 0 }`.
- `__testing_noDelay` — re-exported by `loader.ts` for backwards
compatibility with tests that import the symbol from the class
module.
- `defaultFetch(input: string, init?: RequestInit): Promise<Response>`.
- `hexToBytes(hex: string): Uint8Array` — accepts an optional `0x`
prefix; otherwise expects a sequence of `<hex><hex>` byte digits.
- `maybeDecryptArchive(bytes, keyring, signal, resolver): Promise<Uint8Array>` —
decrypt or pass-through; throws `PackerBrowserError(KEY_UNAVAILABLE)`
when an encrypted body is received with no keyring.
## Implementation Notes
- These helpers are factored out solely to honour CLAUDE.md §3's 300-LOC
hard cap on `loader.ts`; they are not part of the package's public
surface (the loader's tests exercise them indirectly through the
loader, and the `loader/index.ts` barrel does not re-export them).
- `maybeDecryptArchive` deliberately returns the original `Uint8Array`
reference (not a copy) on the pass-through path so the loader does
not allocate twice for the common unencrypted case.
- `hexToBytes` does not validate input length parity; the loader feeds
it only the 128-character signature header (64-byte signature) so
the contract is constrained at the call site.

View File

@@ -0,0 +1,88 @@
# loader-stream
## Purpose
Progressive entry-by-entry unpack helper that powers
`PackedResourceLoader.loadStream`. Callers pass a list of packed-archive
URLs (or `StreamLoadEntry` records with per-entry options) and consume
the returned `AsyncIterableIterator<StreamedLoadedResource>`. Each entry
is dispatched through the standard load pipeline (fetch → maybe-decrypt
→ verify → unpack) in parallel; the iterator yields each result in
**completion order** so a slow fetch can't block a fast neighbour.
## Features
- Completion-order yielding — `Promise.race` over outstanding entries
so the consumer's `for-await` loop wakes up as soon as any entry
finishes.
- Per-entry options merge — `entry.options` overrides the iterator-level
`opts` so a single stream can mix unencrypted and encrypted entries,
different `verifyKey`s, or different mime allow-lists.
- `AbortSignal` honoured at every yield. Pre-aborted signal short-
circuits the iterator without issuing a fetch; a signal that fires
mid-stream throws on the next `next()` call.
- Coexists with the batch `load` path — both call into the same loader
pipeline so behaviour (signature verification, retry policy, OTel
metrics) is identical.
## Usage: Basic Example
```ts
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const manager = createPackedResourceManager();
const urls = ["/a.packed", "/b.packed", "/c.packed"];
for await (const entry of manager.getLoader().loadStream(urls)) {
console.info("decoded", entry.inputUrl, "→", entry.url);
}
```
## Usage: Advanced Example
```ts
import { MemoryKeyring, createPackedResourceManager } from "@suaveplan/packer-browser";
const keyring = new MemoryKeyring({ "tenant-a": new Uint8Array(32).fill(0x11) });
const ac = new AbortController();
const manager = createPackedResourceManager();
const stream = manager.getLoader().loadStream(
[
{ url: "/asset-a.packed" },
{ url: "/asset-b.packed", options: { keyring } },
{ url: "/asset-c.packed", options: { allowedMimePrefixes: ["image/"] } },
],
{ signal: ac.signal }
);
setTimeout(() => ac.abort(), 5000);
for await (const entry of stream) {
consume(entry);
}
```
## API Reference
- `loadStream(loader, inputs, opts?)` — free function used by
`PackedResourceLoader.loadStream`. Not part of the package's public
barrel.
- `StreamLoadEntry` — `{ url: string; options?: PackedResourceLoadOptions }`.
- `StreamedLoadedResource` — `LoadedResource & { inputUrl: string }`.
## Implementation Notes
- The `Promise.race` strategy is bounded — N outstanding fetches at all
times — so callers that want a sliding-window concurrency limit
should chunk their input list externally. A built-in concurrency
cap is intentionally out of scope; the loader's `withRetry` already
smooths transient failures and `@suaveplan/streams` carries the
general windowing primitives for callers that need them.
- A pending entry's `.promise` is created eagerly when `loadStream` is
invoked, not lazily on `next()`. That matches the audit's "stream
sub-resources as bytes arrive" requirement: by starting every fetch
immediately, the iterator yields the first completer with the lowest
possible latency.
- The signal check inside the `while` loop runs before every `await
Promise.race(...)` so a mid-stream abort terminates cleanly without
leaving the iterator suspended on an indefinite race.

View File

@@ -0,0 +1,102 @@
# `loader`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`loader.ts` is the fetch → verify → unpack → Blob-URL pipeline for
packed resources. It is the only place in the package that issues a
network request, the only place that runs `Unpacker.unpack`, and the
only place that allocates a Blob URL. Splitting it out from
`PackedResourceManager` keeps every concern testable in isolation —
fetch retry, signature failure, abort propagation, and OTel histogram
emission can each be exercised without instantiating a cache.
## Features
- `fetch` routed through `@suaveplan/retry`'s `withRetry` with a
single-attempt default policy; callers opt in to retry by passing
`retryConfig`.
- `AbortSignal` honoured at every yield point — pre-fetch, post-fetch,
post-buffer, post-verify, and pre-unpack.
- Optional Ed25519 signature verification when `verifyKey` is supplied
and the response carries an `X-Archive-Signature` header.
- OTel emission: increments `packed_resource_fetch_total` on every
attempt; records `packed_resource_decode_ms` on every successful
pipeline run.
- Typed error surface: every failure produces `PackerBrowserError`
with a `PACKER_BROWSER.*` code and an i18n-resolved message.
- Injectable `fetch` for tests — no `vi.mock` required.
## Usage: Basic
```ts
import {
createPackerBrowserObservability,
PackedResourceLoader,
resolveRuntime,
} from "@suaveplan/packer-browser";
const loader = new PackedResourceLoader({
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
});
const { url, metadata } = await loader.load("/banner.packed");
img.src = url;
```
## Usage: Advanced
```ts
import { createConstantBackoff } from "@suaveplan/retry";
import { PackedResourceLoader } from "@suaveplan/packer-browser";
const controller = new AbortController();
const loader = new PackedResourceLoader({
runtime: resolveRuntime(),
observability,
fetch: async (url) => recordedFetch(url),
});
const out = await loader.load("/banner.packed", {
verifyKey: trustAnchorPublicKey,
signal: controller.signal,
retryConfig: {
maxAttempts: 3,
delay: createConstantBackoff({ delay: 250 }),
},
});
```
## API Reference
- `new PackedResourceLoader(opts: PackedResourceLoaderOptions)`.
- `loader.load(url: string, opts?: PackedResourceLoadOptions):
Promise<LoadedResource>`.
- Throws `RESOURCE_NOT_FOUND` for non-2xx.
- Throws `ARCHIVE_SIGNATURE_INVALID` for a non-verifying signature
when `verifyKey` is supplied.
- Throws `LOAD_FAILED` when `Unpacker.unpack` rejects.
- Throws `ABORTED` when the supplied signal fires.
- `PackedResourceLoadOptions`:
- `verifyChecksum?: boolean` — default `false`.
- `verifyKey?: Uint8Array` — optional Ed25519 public key.
- `signal?: AbortSignal` — checked at every yield.
- `retryConfig?: RetryConfig` — default: single attempt.
- `messageResolver?: MessageResolver` — overrides the constructor's
resolver for this call.
## Implementation Notes
Cluster A's loader treats every fetched payload as a raw `PackedData`
whose metadata is reconstructed from the bytes; signature verification
covers the **archive transport** layer, while integrity verification
is delegated to the `Unpacker` and currently skipped
(`verifyIntegrity: false`) because Cluster A archives do not carry the
BLAKE3 integrity bundle. Cluster B introduces the formal manifest
decoder and re-enables integrity. The `X-Archive-Signature` header
contract is unchanged across clusters — switching to a separate
signature manifest is a Cluster B concern only.

View File

@@ -0,0 +1,67 @@
# manager-helpers
## Purpose
Small free helpers extracted from `manager.ts` to keep the class file
under CLAUDE.md §3's 300-line hard cap. Both helpers (`deriveCacheKey`
and `checkAbortedSignal`) are exercised through the manager's tests;
they have no state and can be reused by any future internal collaborator
that needs the same primitives.
## Features
- `deriveCacheKey(url, verifyKey?)` — stable cache key derivation that
appends the first 8 bytes (hex) of the verify-key as a suffix so
two loads against the same URL with different verify-keys live in
separate cache slots.
- `checkAbortedSignal(signal, resolver, key?)` — throw
`PackerBrowserError(ABORTED)` when the supplied signal is already
aborted; tolerates `undefined` for both `signal` and `resolver` so
it works at every call site.
## Usage: Basic Example
```ts
import { checkAbortedSignal, deriveCacheKey } from "./manager-helpers.js";
const key = deriveCacheKey("/asset.packed");
const keyed = deriveCacheKey("/asset.packed", new Uint8Array(64));
checkAbortedSignal(new AbortController().signal, undefined);
```
## Usage: Advanced Example
```ts
import { checkAbortedSignal, deriveCacheKey } from "./manager-helpers.js";
import { defaultPackerBrowserResolver } from "../i18n/messages.js";
const ac = new AbortController();
ac.abort();
try {
checkAbortedSignal(
ac.signal,
defaultPackerBrowserResolver,
"packer-browser.error.ABORTED.preload"
);
} catch (err) {
console.error("preload aborted", err);
}
```
## API Reference
- `deriveCacheKey(url: string, verifyKey?: Uint8Array): string`.
- `checkAbortedSignal(signal: AbortSignal | undefined, resolver?: MessageResolver, key?: string): void`.
## Implementation Notes
- The 8-byte cap on the verify-key prefix balances key uniqueness
against cache-key memory cost. A full key would be 32 bytes hex-
encoded (64 chars) per entry; the 8-byte prefix is enough to
discriminate adversarially-distinct keys.
- `checkAbortedSignal` accepts a custom message key so the same helper
works for `manager.load`, `manager.preload`, `manager.loadStream`,
and future async surfaces.
- These helpers are NOT part of the public barrel — they're internal
implementation details of the manager. Direct imports from outside
the manager module are discouraged.

View File

@@ -0,0 +1,86 @@
# manager-stream
## Purpose
Free-function implementation of `PackedResourceManager.loadStream`,
factored out of `manager.ts` so the class stays under the CLAUDE.md
§3 300-line hard cap. The function drives the loader's streaming
iterator, inserts each decoded entry into the manager's cache, and
emits a `load` event before yielding the entry to the caller — so
streaming consumers see exactly the same cache-priming behaviour as
batch consumers.
## Features
- Threads the manager's default `MessageResolver` and feature-flag
provider through every loader call via `buildLoaderOptions`.
- Inserts each yielded entry into the cache **before** yielding so a
consumer that calls `manager.load(inputUrl)` immediately after
receiving an entry hits the cache (no second fetch).
- `AbortSignal` propagation is honoured at entry time (via
`manager._checkAbortedExt`) and re-honoured by the underlying
`loader.loadStream` iterator at each yield.
- Per-entry options (`StreamLoadEntry.options`) override the manager-
level options, so a single stream can mix `verifyKey`s, keyrings,
and mime allow-lists.
## Usage: Basic Example
```ts
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const manager = createPackedResourceManager();
for await (const entry of manager.loadStream([
"/asset-a.packed",
"/asset-b.packed",
])) {
console.info("decoded", entry.inputUrl, "→", entry.url);
}
```
## Usage: Advanced Example
```ts
import {
createPackedResourceManager,
MemoryKeyring,
} from "@suaveplan/packer-browser";
const manager = createPackedResourceManager();
const keyring = new MemoryKeyring({ "v1": new Uint8Array(32).fill(7) });
const ac = new AbortController();
for await (const entry of manager.loadStream(
[
{ url: "/plain.packed" },
{ url: "/encrypted.packed", options: { keyring } },
],
{ signal: ac.signal }
)) {
if (entry.inputUrl === "/plain.packed") ac.abort();
process(entry);
}
```
## API Reference
- `managerLoadStream(manager, opts, inputs)` — internal entrypoint
used by `PackedResourceManager.loadStream`. Not part of the package
barrel.
- `ManagerStreamedEntry` — `{ url, inputUrl, metadata }`.
## Implementation Notes
- The function takes the `inputs` array as the trailing parameter so
the manager can pass `opts` early; this keeps the method signature
in `manager.ts` to a single line and avoids type-juggling in the
delegation.
- `_streamInsert`, `_getDefaultResolver`, and `_checkAbortedExt` on the
manager are intentionally `_`-prefixed: they exist only to give this
free function access to the manager's internals without exposing
cache/resolver/abort plumbing on the public surface. They are not in
the package barrel.
- The manager's `cacheHit` / `cacheMiss` events are NOT emitted from
the streaming path — every streamed entry is, by definition, a
fresh load. Consumers that need pre-stream cache lookups should
combine `cache.has(...)` with a batch `load`.

View File

@@ -0,0 +1,105 @@
# `manager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`manager.ts` is the lifecycle façade that consumers interact with. It
composes a `PackedResourceCache` and a `PackedResourceLoader`, owns
the cache-key derivation policy, emits typed lifecycle events
(`load` / `cacheHit` / `cacheMiss` / `cacheEvict` / `error`), exposes
the resolved OTel observability bundle and feature-flag provider, and
implements `AbortSignal` propagation on every public async entrypoint.
The split keeps every collaborator under 300 LOC per CLAUDE.md §3.
## Features
- Explicit factory: `createPackedResourceManager(opts?)` produces a
fresh instance per call; the default `.` entrypoint does NOT
auto-construct a module-level singleton.
- Typed event emitter (sync dispatch) covering five lifecycle events.
- Cache-aware `load(url, opts?)` with on / off cache modes, refCount
release, and stale-eviction support via `cleanupExpired()`.
- Parallel `preload(hints, signal?)` with `AbortSignal` honoured
before any IO starts.
- `getObservability()` / `getFlagProvider()` accessors for adopters
that want to introspect the wiring.
- `clearCache()` emits a `cacheEvict` per evicted URL so observers
can update bookkeeping.
## Usage: Basic
```ts
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const manager = createPackedResourceManager();
manager.on("load", ({ url, metadata }) =>
console.log(`loaded ${url} (${metadata.originalSize} B)`)
);
const blobUrl = await manager.load("/banner.packed");
imgEl.src = blobUrl;
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing";
import {
createPackedResourceManager,
type PackedResourceManagerEvents,
} from "@suaveplan/packer-browser";
const clock = new FakeClock({ now: 0 });
const manager = createPackedResourceManager({
clock: () => clock.now(),
maxCacheSize: 4,
maxCacheAge: 10_000,
fetch: customFetch,
meter: testMeter,
});
manager.on("cacheEvict", ({ url }) => recorded.push(url));
const ac = new AbortController();
await manager.preload(
[
{ url: "/a.packed" },
{ url: "/b.packed", verifyKey: trustAnchor },
],
ac.signal
);
clock.tick(60_000);
const evicted = manager.cleanupExpired();
// evicted === 2; recorded contains both URLs
```
## API Reference
- `createPackedResourceManager(opts?: PackedResourceManagerOptions):
PackedResourceManager`.
- `manager.load(url, opts?): Promise<string>` — returns the Blob URL.
- `manager.preload(hints, signal?): Promise<void>`.
- `manager.release(url, verifyKey?): void`.
- `manager.cleanupExpired(): number`.
- `manager.clearCache(): void`.
- `manager.getCacheStats(): CacheStats`.
- `manager.getObservability(): PackerBrowserObservability`.
- `manager.getFlagProvider(): FlagProvider`.
- Events: `load`, `error`, `cacheHit`, `cacheMiss`, `cacheEvict`.
## Implementation Notes
The manager's event-emitter is a minimal in-file `TinyEmitter` rather
than `@suaveplan/events`'s `EventBus`. The latter carries
back-pressure, async dispatch, and interceptor policy the manager
does not need; copying a 40-line emitter inline keeps the dep graph
narrower and the test surface tighter. Cache-key derivation includes
the first 8 bytes of `verifyKey` hex-encoded — that matches the
upstream form without the unsafe `Buffer` dependency. The release
semantics are advisory: a caller that drops a Blob URL without
calling `release` leaks one ref slot, but the eviction policy will
still drain the cache once `clearCache` is called.

View File

@@ -0,0 +1,90 @@
# tiny-emitter
## Purpose
Minimal generic typed event emitter used internally by
`PackedResourceManager`. Factored out of `manager.ts` so the class
file stays below CLAUDE.md §3's 300-line hard cap, and so the emitter
can be reused by any future internal class that needs typed sync
event dispatch without paying for `@suaveplan/events`' interceptor and
backpressure machinery.
## Features
- Generic over `TEvents extends Record<string | symbol, unknown[]>` —
callers describe their event map and every `on`/`off`/`emit` call
is type-checked against it.
- `on(event, listener)` returns an unsubscribe function — pairs with
`off(event, listener)` so consumers can choose the shape they
prefer.
- Synchronous dispatch — `emit` runs every listener inline so a
consumer that calls `emit("load", …)` knows every listener has
completed before the next line runs. The manager relies on this
for cache-priming ordering.
- Zero runtime dependencies; no allocations beyond the listener sets.
## Usage: Basic Example
```ts
import { TinyEmitter } from "./tiny-emitter.js";
interface Events {
ping: [time: number];
[key: string | symbol]: unknown[];
}
const emitter = new TinyEmitter<Events>();
const unsubscribe = emitter.on("ping", (t) => console.info(t));
// emitter is consumed by a subclass via the protected `emit`.
unsubscribe();
```
## Usage: Advanced Example
```ts
import { TinyEmitter } from "./tiny-emitter.js";
interface LoaderEvents {
progress: [{ url: string; bytes: number }];
error: [{ url: string; error: Error }];
[key: string | symbol]: unknown[];
}
class Loader extends TinyEmitter<LoaderEvents> {
public report(url: string, bytes: number): void {
// protected `emit` is invoked from inside the subclass.
this.emit("progress", { url, bytes });
}
}
const l = new Loader();
l.on("progress", ({ url, bytes }) => console.info(url, bytes));
l.report("/a.packed", 1024);
```
## API Reference
- `TinyEmitter<TEvents>` — abstract-ish base class.
- `on<K>(event, listener): () => void` — register; returns an
unsubscribe function.
- `off<K>(event, listener): void` — explicit removal.
- `emit<K>(event, ...args): void` — `protected`; subclasses call it
to dispatch.
- `EmitterListener<TEvents, K>` — function type:
`(...args: TEvents[K]) => void`.
## Implementation Notes
- The internal `listeners` map keys on `keyof TEvents`. The `Set`
values are typed using `EmitterListener<TEvents, keyof TEvents>` and
cast back to the per-key listener type at the call site; the cast
is sound because the `Set` only ever sees listeners installed for
the matching `event` key.
- Synchronous dispatch means a listener that throws will bubble out of
`emit`. The manager wraps cache-emit calls in a `try { emit(...) }`
where forward-progress matters; downstream listeners are expected
to handle their own errors (the audit explicitly bans the package
from swallowing them silently).
- `TinyEmitter` is not exported from the package barrel — it's an
internal implementation detail of the manager. Consumers needing a
fully-featured event bus should use `@suaveplan/events`.

View File

@@ -0,0 +1,78 @@
# `observability`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`observability.ts` creates the OpenTelemetry instrument bundle for
`@suaveplan/packer-browser`. The package emits four named instruments
(`packed_resource_fetch_total`, `packed_resource_cache_hits_total`,
`packed_resource_cache_miss_total`, `packed_resource_decode_ms`) from
production paths; consumers wire their own `MeterProvider` /
`TracerProvider` to receive them. When no provider is supplied the
factory falls back to the `@opentelemetry/api` no-op providers, so the
package is safe to import in environments without telemetry plumbing.
## Features
- Three counters covering the cache/loader event surface plus one
histogram for the loader's end-to-end decode latency.
- Lazy creation on the supplied `Meter` — no instrument is allocated
until `createPackerBrowserObservability` is called, and the function
may be called more than once per process to create independent
bundles (used by tests).
- Compatible with `@opentelemetry/sdk-metrics`'s in-memory
`MeterProvider` for unit-test assertion (no `vi.mock` required).
- Re-uses `@suaveplan/opentelemetry`'s `createCounter` /
`createHistogram` helpers so descriptions and units stay consistent
across the project.
## Usage: Basic
```ts
import { createPackerBrowserObservability } from "@suaveplan/packer-browser";
const observability = createPackerBrowserObservability();
observability.counters.cacheHitsTotal.add(1);
observability.histograms.decodeMs.record(12.5);
```
## Usage: Advanced
```ts
import { MeterProvider, InMemoryMetricExporter } from "@opentelemetry/sdk-metrics";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const exporter = new InMemoryMetricExporter(0 /* DELTA */);
const provider = new MeterProvider({});
provider.addMetricReader({ collect: async () => exporter.export(/* ... */) });
const meter = provider.getMeter("test");
const manager = createPackedResourceManager({ meter });
await manager.load("/banner.packed").catch(() => undefined);
// exporter now contains packed_resource_fetch_total = 1
```
## API Reference
- `createPackerBrowserObservability(opts?)` — returns a
`PackerBrowserObservability` bundle.
- `opts.observability?` — pass an already-built bundle to short-circuit.
- `opts.meter?` / `opts.tracer?` — override the providers used for
`createCounter` / `createHistogram` / `getTracer`.
- `PackerBrowserObservability` — `{ meter, tracer, counters, histograms }`.
- `PackerBrowserCounters` — `fetchTotal`, `cacheHitsTotal`, `cacheMissTotal`.
- `PackerBrowserHistograms` — `decodeMs`.
## Implementation Notes
The instrument names follow Cluster A's contract exactly — renaming a
counter or histogram is a breaking change that has to flow through a
new spec change with a `## Migration` section. The bundle does not
hold a reference to the `MeterProvider`; the OTel SDK owns lifecycle.
Tests build a real in-memory `MeterProvider` and pass it on `opts.meter`
rather than mocking — CLAUDE.md rule 2 bans `vi.mock` in non-test
infrastructure packages and the assertions are richer (you check the
exported metric value, not that a function was called).

View File

@@ -0,0 +1,103 @@
# `prefetch` — BackgroundFetch integration
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `prefetch` module wraps the Chromium `BackgroundFetchManager` API so
an application can preload an archive on idle without holding the page
open. The page side calls `prefetchPackedArchive(url, opts)` to kick off
the registration; the Service Worker listens for
`backgroundfetchsuccess` and calls `handleBackgroundFetchSuccess` to
drain the records into the same cache the rest of `@suaveplan/packer-browser`
uses. Failures and aborts route through `handleBackgroundFetchFailure`.
These helpers are PACKER-BROWSER-20's production surface. They are
feature-detected — if the browser does not implement BackgroundFetch
(`navigator.serviceWorker.ready.backgroundFetch` is undefined and no
`backgroundFetch` is injected), `prefetchPackedArchive` resolves with
`null` and the caller's flow continues with a regular fetch.
## Features
- API feature-detection — graceful `null` when BackgroundFetch is absent
- Pluggable `backgroundFetch` injection for tests
- OTel counters: `packed_resource_prefetch_success_total` and
`packed_resource_prefetch_failure_total`
- Cache-drain helper writes every record's response into the configured
cache (default `packed-resources-v1`) so the SW's existing fetch
handler hits cached entries on the next request
## Usage: Basic
```ts
import { prefetchPackedArchive } from "@suaveplan/packer-browser/prefetch";
// Page side:
const reg = await prefetchPackedArchive("/big.bundle.asset");
if (reg !== null) console.log("BG fetch started:", reg.id);
```
```ts
// Service worker side:
import { handleBackgroundFetchSuccess } from "@suaveplan/packer-browser/prefetch";
self.addEventListener("backgroundfetchsuccess", (event) => {
event.waitUntil(
handleBackgroundFetchSuccess(event, {
observability,
cacheName: "packed-resources-v1",
})
);
});
```
## Usage: Advanced
```ts
import {
handleBackgroundFetchFailure,
handleBackgroundFetchSuccess,
prefetchPackedArchive,
} from "@suaveplan/packer-browser/prefetch";
const reg = await prefetchPackedArchive("/heavy.bundle.asset", {
id: "heavy-asset",
options: {
title: "Pre-loading heavy asset",
icons: [{ src: "/icon-192.png", sizes: "192x192" }],
},
observability,
});
// Failure listener — counts every abort.
self.addEventListener("backgroundfetchfail", (event) =>
handleBackgroundFetchFailure(event, { observability })
);
```
## API Reference
- `prefetchPackedArchive(archiveUrl, opts?): Promise<BackgroundFetchRegistrationLike | null>`
- `handleBackgroundFetchSuccess(event, { caches?, cacheName?, observability }): Promise<void>`
- `handleBackgroundFetchFailure(event, { observability }): void`
Every helper accepts a fully injectable cache storage / background
fetch manager so tests can exercise real failure modes (cache write
rejection, missing API, registration with zero records) without any
mocking.
## Implementation Notes
- `prefetchPackedArchive` never reads the `navigator` global directly:
production callers receive the `BackgroundFetchManager` from
`await navigator.serviceWorker.ready` and pass it in. This keeps the
helper safe to call from non-DOM contexts (workers, tests).
- The success handler clones every response before writing to the cache
so the body stream is consumed exactly once.
- Failure counter increments are emitted from BOTH
`prefetchPackedArchive` (when the underlying `fetch()` rejects) AND
`handleBackgroundFetchFailure` (for SW-side abort events) so dashboards
show every failure regardless of which layer caught it.

View File

@@ -0,0 +1,88 @@
# `preload` — `<link rel="preload">` hint generation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`generatePreloadHints` derives an ordered list of `<link rel="preload">`
hints from a packed manifest, and `injectPreloadHints` appends those
hints to a document's `<head>`. Together they let the Service Worker
(or a one-off bootstrap script) preload critical packed entries before
the browser's HTML parser would have requested them.
These helpers are PACKER-BROWSER-18's production surface. They are pure
and feature-detected — `injectPreloadHints` is safe to call from a
Service Worker context (`document` is `undefined`) and will simply
return an empty array.
## Features
- `preload: true` selection (default) or descending-priority top-N
- Stable tiebreak for ties: lexicographic ordering on `url`
- `as` defaults to `"fetch"` when absent — preserves correct dispatch
- `crossOrigin` pass-through to the emitted `<link>`
- OTel `fetchTotal` increments when an observability bundle is supplied
## Usage: Basic
```ts
import {
generatePreloadHints,
injectPreloadHints,
} from "@suaveplan/packer-browser/preload";
const hints = generatePreloadHints({
entries: [
{ url: "/hero.image.packed", preload: true, as: "image" },
{ url: "/font.woff2", preload: true, as: "font" },
],
});
injectPreloadHints(document, hints);
```
## Usage: Advanced
```ts
import { createPackerBrowserObservability } from "@suaveplan/packer-browser";
const observability = createPackerBrowserObservability({ meter });
const topThree = generatePreloadHints(
{
entries: [
{ url: "/a.packed", priority: 1 },
{ url: "/b.packed", priority: 5 },
{ url: "/c.packed", priority: 3 },
],
},
{ topN: 3, observability }
);
// emits 3 fetchTotal increments
```
## API Reference
- `generatePreloadHints(manifest, opts?): readonly PreloadHintLink[]`
- `manifest.entries: ReadonlyArray<PreloadManifestEntry>`
- `opts.topN?: number` — override the default `preload: true` filter
- `opts.observability?: PackerBrowserObservability` — emit counters
- `injectPreloadHints(doc, hints): readonly HTMLLinkElement[]`
- Returns `[]` when `doc` is `undefined` or has no `<head>`.
- Each created element carries `rel="preload"`, the configured
`href`, `as`, and optional `crossorigin` attributes.
## Implementation Notes
- The OTel emission is intentionally one counter per emitted hint —
not one per manifest entry — so the count tracks bytes that actually
get preload-requested by the browser.
- `topN` is clamped to `[0, manifest.entries.length]` so a negative
value yields an empty array without throwing.
- The helper does not de-duplicate URLs — manifests are the source of
truth for ordering and duplicates.
- `injectPreloadHints` does not handle the non-DOM environment by
feature-flag; it relies on `doc === undefined` or `doc.head === null`
so a hand-rolled adapter can drop a fake document in tests.

View File

@@ -0,0 +1,94 @@
# range-response
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7233 §2.1 single-range `Response` builder for packed-resource
delivery from the Service Worker. Browsers ask for ranges when streaming
audio, scrubbing video, or resuming a download — this module is the
helper the SW invokes to serve those ranges out of the already-decoded
bytes it cached. The helper keeps SW code small: parse, branch, build
`Response`. No state, no allocations beyond the response body slice.
## Features
- Single-range `bytes=<start>-<end>` parsing with normalised
open-ended (`bytes=100-`) and suffix (`bytes=-100`) forms.
- `206 Partial Content` with `Content-Range`, `Accept-Ranges: bytes`,
and `Content-Length` headers when the request is a valid single
range above the configured threshold.
- `200 OK` with `Accept-Ranges: bytes` for unranged requests or bodies
below the threshold (default 256 KiB).
- `416 Range Not Satisfiable` for multi-range or malformed ranges, with
a `Content-Range: bytes */<total>` header per spec.
- `412 Precondition Failed` for `If-Range` ETag mismatches.
- `Accept-Ranges: bytes` advertised on every successful response so
clients know the resource supports ranges going forward.
## Usage: Basic Example
```ts
import { serveRangeResponse } from "@suaveplan/packer-browser";
self.addEventListener("fetch", (event) => {
const req = (event as FetchEvent).request;
const body = getCachedBody(req); // application-specific
(event as FetchEvent).respondWith(
serveRangeResponse(req, body, { contentType: "audio/mpeg" })
);
});
```
## Usage: Advanced Example
```ts
import {
serveRangeResponse,
DEFAULT_RANGE_THRESHOLD_BYTES,
} from "@suaveplan/packer-browser";
function buildResponse(req: Request, body: Uint8Array, etag: string) {
return serveRangeResponse(req, body, {
contentType: "video/mp4",
etag,
// Lower the threshold for small clips that benefit from range
// hopping (e.g. sprite-sheet image maps).
rangeThreshold: DEFAULT_RANGE_THRESHOLD_BYTES / 8,
});
}
```
## API Reference
- `parseRange(header: string | null, totalBytes: number): ParseRangeResult`
- Returns `null` when header is absent or non-bytes unit.
- Returns `"multi-range"` when the header carries more than one range.
- Returns `"invalid"` when malformed or out-of-bounds.
- Returns `ParsedByteRange { start, end }` otherwise.
- `serveRangeResponse(request: Request, body: Uint8Array, opts?: ServeRangeResponseOptions): Response`
- `opts.contentType` — defaults to `application/octet-stream`.
- `opts.etag` — when present, enables `If-Range` evaluation.
- `opts.rangeThreshold` — minimum body size to enable 206 (default
`262144`).
- `DEFAULT_RANGE_THRESHOLD_BYTES: number` — `262144`.
## Implementation Notes
- The 256 KiB default threshold is chosen empirically: below this size
the cost of slicing + emitting a 206 outweighs the saving for typical
consumers (a single round-trip with the full body is cheaper). The
threshold is fully configurable via `RunServiceWorkerOptions.rangeThreshold`.
- The helper does NOT support multi-range responses (`multipart/byteranges`).
RFC 7233 explicitly permits servers to return either 416 or fall back
to 200; this implementation chooses 416 to surface the limit
immediately so clients can issue single-range follow-ups.
- `If-Range` is interpreted as strong ETag equality. Date-based
`If-Range` values are treated as etags and will mismatch any
configured etag; callers that want HTTP-date semantics should set
`opts.etag` to the date string they expect.
- Every successful response advertises `Accept-Ranges: bytes`, even the
below-threshold 200, so the next client request can choose to issue a
ranged follow-up.

View File

@@ -0,0 +1,79 @@
# `runtime`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runtime.ts` centralises the three injectable platform primitives
(`Clock`, `MonotonicClock`, `Rng`) that every production path in
`@suaveplan/packer-browser` reads time and randomness through. Direct
`Date.now()`, `Math.random()`, and `performance.now()` calls are banned
outside this file so seeded-deterministic tests can pin every
observable behaviour. The shape mirrors `@suaveplan/packer/runtime`, so
adopters that already inject runtime into the packer get the same
contract here.
## Features
- `defaultClock` / `defaultMonotonicClock` / `defaultRng` — wall-clock
and CSPRNG defaults wired to the platform globals.
- `resolveRuntime(opts?)` — merges caller-supplied `RuntimeOptions`
against the platform defaults to produce a fully-populated
`PackerBrowserRuntime` bundle.
- `elapsedMs(monotonicClock, startNanos)` — convenience for the
histogram observation sites that previously did
`performance.now() - start` directly.
- Adapter that upgrades a seeded `Rng` lacking `nextBytes` into the
runtime-shape RNG by deriving bytes from `nextUint32`, so tests can
inject `@suaveplan/testing`'s `seededRng()` unchanged.
## Usage: Basic
```ts
import { resolveRuntime } from "@suaveplan/packer-browser";
const runtime = resolveRuntime();
const now = runtime.clock(); // ms since epoch
const seed = runtime.rng.nextBytes(16); // CSPRNG bytes
```
## Usage: Advanced
```ts
import { FakeClock, seededRng } from "@suaveplan/testing";
import { resolveRuntime } from "@suaveplan/packer-browser";
const clock = new FakeClock({ now: 1_700_000_000_000 });
const runtime = resolveRuntime({
clock: () => clock.now(),
rng: seededRng(42),
});
// runtime.clock() returns 1_700_000_000_000 until FakeClock is advanced
```
## API Reference
- `defaultClock: Clock` — `() => Date.now()`.
- `defaultMonotonicClock: MonotonicClock` — wraps `performance.now()`,
returns nanoseconds as `bigint`.
- `defaultRng: Required<Pick<Rng, "nextBytes">> & Rng` — CSPRNG bound
to `globalThis.crypto`.
- `resolveRuntime(opts?: RuntimeOptions): PackerBrowserRuntime` — fills
in defaults for absent fields.
- `elapsedMs(monotonicClock, startNanos: bigint): number` — returns
elapsed milliseconds as a float.
- `RuntimeOptions` / `PackerBrowserRuntime` — option / resolved
bundles.
## Implementation Notes
Monotonic time is exposed as `bigint` nanoseconds even though most
consumers ultimately reduce to millisecond floats. The `bigint`
discipline prevents accidental rounding when sampling the clock at
nanosecond precision; the `elapsedMs` helper exists so call sites that
need the float form never reach for `performance.now()` directly.
`nextBytes` is part of the resolved RNG shape because the upcoming
Cluster B encrypted-archive keyring needs CSPRNG bytes and the manager
will forward the resolved RNG to it.

View File

@@ -0,0 +1,90 @@
# `service-worker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`service-worker.ts` is the production Service Worker entrypoint
exposed via the `/service-worker` subpath. Its only public function,
`runPackedResourceServiceWorker(scope, opts?)`, registers four
handlers — `install`, `activate`, `fetch`, and `message` — against
the supplied scope. In production the scope is the real
`ServiceWorkerGlobalScope`; in tests it is the in-memory shim from
`sw-shim.ts`. Cluster A intentionally caches the **packed** bytes in
the SW (un-tagged) and lets the `PackedResourceManager` on the page
do the unpack; Cluster B adds SW-side unpack with range support.
## Features
- `install` → `scope.skipWaiting()` so the new worker activates on
the first navigation rather than waiting for every tab to close.
- `activate` → deletes every cache whose name is not the configured
`cacheName`, then calls `scope.clients.claim()` so existing tabs
start routing through the new worker immediately.
- `fetch` → matches `packedPatterns` (default
`[/\.packed$/, /\?packed=true/]`) and intercepts only those URLs.
Cache hits are served verbatim; misses trigger an upstream fetch
and a cache write tagged `X-Unpacked: true`.
- `message` → handles `CACHE_CLEAR` (deletes the cache) and
`CACHE_STATUS` (replies with `{ cacheSize, urls }` on
`event.ports[0]`).
- Injectable `fetch` for tests — no `vi.mock` required.
## Usage: Basic
In your production SW bundle (`service-worker.js`):
```ts
import { runPackedResourceServiceWorker } from "@suaveplan/packer-browser/service-worker";
runPackedResourceServiceWorker(
self as unknown as ServiceWorkerGlobalScope
);
```
## Usage: Advanced
```ts
import {
createServiceWorkerShim,
runPackedResourceServiceWorker,
} from "@suaveplan/packer-browser/service-worker";
const scope = createServiceWorkerShim();
runPackedResourceServiceWorker(scope, {
cacheName: "test-cache",
packedPatterns: [/^https:\/\/cdn\.example\.com\/.+\.packed/],
fetch: async (url) => new Response(testBytes),
});
const request = new Request("https://cdn.example.com/a.packed");
const fetchEvent = makeFetchEventLike(request);
scope.dispatchEvent(fetchEvent);
const response = await fetchEvent.responsePromise;
// response.headers.get("X-Unpacked") === "true"
```
## API Reference
- `runPackedResourceServiceWorker(scope, opts?: RunServiceWorkerOptions): void`.
- `RunServiceWorkerOptions`:
- `cacheName?: string` — default `"packed-resources-v1"`.
- `packedPatterns?: ReadonlyArray<RegExp>` — default
`[/\.packed$/, /\?packed=true/]`.
- `fetch?: FetchLike` — default `globalThis.fetch`.
- Errors raised inside the fetch handler are `PackerBrowserError`
with `LOAD_FAILED` (network) or `RESOURCE_NOT_FOUND` (non-2xx)
codes; they propagate out of `respondWith` as a rejected promise.
## Implementation Notes
The SW deliberately does not call into `PackedResourceManager` from
inside its scope — the manager owns Blob URL allocation, which is a
page-scope concern (a SW that calls `URL.createObjectURL` produces
URLs the page cannot resolve). The SW's role is purely to cache the
**packed** bytes and short-circuit re-fetch. The page-side manager
sees the cached response (or a fresh network response) and runs the
unpack. This split keeps the SW small enough to register on a flaky
connection without bundling the entire Unpacker pipeline.

View File

@@ -0,0 +1,81 @@
# `sw-shim`
## Purpose
`sw-shim.ts` provides `createServiceWorkerShim()`, a hand-rolled
in-memory implementation of `ServiceWorkerGlobalScope` sufficient to
register and exercise the production SW handlers in a Node test
process. The shim is a **real implementation** — every method does
the work it claims to do, no `vi.mock` is permitted by CLAUDE.md rule
2. Tests register the production `runPackedResourceServiceWorker`
against the shim and dispatch `FetchEvent` / `ExtendableEvent` /
`MessageEvent` instances built from `globalThis` constructors.
## Features
- `addEventListener(type, handler)` registers the handler against a
type-keyed `Map<string, Set<EventListener>>`.
- `dispatchEvent(event)` walks the matching set and invokes each
handler synchronously, returning the standard `boolean`.
- `caches` is a real `Map<string, Map<url, Response>>`-backed
`CacheStorage`; `open` / `match` / `put` / `keys` / `delete` all
behave like the platform API.
- `clients.claim()` and `skipWaiting()` resolve immediately and
record their call counts on `record` so tests can assert lifecycle
invariants like "skipWaiting was called once".
- No `vi.fn` / `vi.mock` anywhere — the entire test surface
observes real method behaviour via the `record` snapshot or the
cache state.
## Usage: Basic
```ts
import {
createServiceWorkerShim,
runPackedResourceServiceWorker,
} from "@suaveplan/packer-browser";
const scope = createServiceWorkerShim();
runPackedResourceServiceWorker(scope);
scope.dispatchEvent(new Event("install"));
// scope.record.skipWaitingCalls === 1
```
## Usage: Advanced
```ts
import { createServiceWorkerShim } from "@suaveplan/packer-browser";
const scope = createServiceWorkerShim();
const cache = await scope.caches.open("ephemeral");
await cache.put("/a", new Response("payload"));
const hit = await cache.match("/a");
const text = await hit?.text();
// text === "payload"
// Dispatch into a custom listener
scope.addEventListener("custom", (ev) => observed.push(ev.type));
scope.dispatchEvent(new Event("custom"));
```
## API Reference
- `createServiceWorkerShim(): ServiceWorkerShim`.
- `ServiceWorkerShim`:
- `addEventListener(type, handler)` / `dispatchEvent(event)`.
- `skipWaiting(): Promise<void>` (records call).
- `clients: { claim(): Promise<void> }` (records call).
- `caches: ShimCacheStorage`.
- `record: ShimRecord` — `{ skipWaitingCalls, clientsClaimCalls }`.
## Implementation Notes
The shim deliberately does NOT reproduce browser quirks like Cache
Storage partitioning by origin, the FetchEvent network fall-through,
or the per-message-port `start()` requirement. Those behaviours are
covered by the Playwright E2E suite, not the unit tests; the unit
tests are concerned with the SW handler logic ("a packed URL
intercepted by the production code path produces a cache write")
which the shim faithfully exposes. Tests construct `Event`, `Request`,
and `Response` objects via `globalThis` (happy-dom in vitest) rather
than reinventing them inside the shim.

View File

@@ -0,0 +1,80 @@
# `signature`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`signature.ts` is the Ed25519 signature-verification gate run by the
loader before any packed bytes are handed to `Unpacker.unpack`. It
exists because the upstream audit (`enterprise/packer-browser.md` §3.5)
explicitly required archive-signature verification — without it, an
attacker who can substitute the bytes at the packed URL can ship
arbitrary content to the page. By verifying the manifest signature
under a caller-supplied public key first, the loader gives consumers a
hard cryptographic anchor before any decompression / decryption runs.
## Features
- Thin wrapper over `@suaveplan/crypto`'s `ed25519.verify` — single
authoritative implementation, no parallel curve code.
- Boundary defence: a signature whose length is not 64 bytes throws
`PackerBrowserError(ARCHIVE_SIGNATURE_INVALID)` rather than silently
returning `false`. This catches wire corruption / header mis-parse
before it pollutes downstream logs.
- Returns a boolean for cryptographic verify-or-not — never throws on
a non-verifying-but-well-formed signature (that's a domain decision
the caller makes; the loader throws on the bool side).
- Optional `messageResolver` for i18n on the boundary throw.
## Usage: Basic
```ts
import { verifyArchiveSignature } from "@suaveplan/packer-browser";
const ok = verifyArchiveSignature(manifestBytes, signature, publicKey);
if (!ok) throw new Error("signature did not verify");
```
## Usage: Advanced
```ts
import { ed25519 } from "@suaveplan/crypto";
import { verifyArchiveSignature } from "@suaveplan/packer-browser";
const { publicKey, secretKey } = ed25519.generateKeypair();
const message = new TextEncoder().encode("manifest:v1");
const sig = ed25519.sign(secretKey, message);
const valid = verifyArchiveSignature(message, sig, publicKey);
// valid === true
const tampered = new Uint8Array(sig);
tampered[0] ^= 1;
const invalid = verifyArchiveSignature(message, tampered, publicKey);
// invalid === false
```
## API Reference
- `verifyArchiveSignature(manifestBytes, signature, publicKey, opts?)`
— returns `boolean`.
- Throws `PackerBrowserError(ARCHIVE_SIGNATURE_INVALID)` when
`signature.length !== 64`.
- Forwards `publicKey` to `ed25519.verify`, which throws if the key
length is not 32 bytes.
- `VerifyArchiveSignatureOptions`:
- `messageResolver?: MessageResolver` — resolver for the boundary
error message.
## Implementation Notes
Signature material is conventionally hex-encoded in the
`X-Archive-Signature` response header; the loader decodes it before
calling this function. The public key is **not** sourced from the wire
— the loader requires the caller to pass `verifyKey` on
`PackedResourceLoadOptions`, ensuring an out-of-band trust anchor.
Cluster B's encrypted-archive keyring adds the per-key public-key
selection step on top of this primitive; Cluster A simply takes one
public key per `load(...)` call.

View File

@@ -0,0 +1,67 @@
# `singleton`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`singleton.ts` exposes the module-level shared
`PackedResourceManager` instance behind the
`@suaveplan/packer-browser/singleton` subpath. The default `.`
entrypoint deliberately **omits** this export so consumers that build
their own manager (e.g. with a custom `fetch`, observability bundle,
or feature-flag provider) pay zero import-time cost for the global
instance. Adopters that want a shared instance opt in explicitly.
## Features
- Eager construction at module load via `createPackedResourceManager()`
with platform defaults — `globalThis.fetch`, `URL.revokeObjectURL`,
no-op OTel providers, empty `MemoryProvider` flags.
- Stable reference across imports — every consumer that imports the
symbol receives the same `PackedResourceManager` instance.
- Lives at its own subpath so tree-shaking eliminates the singleton
(and its constructor cost) from bundles that do not import it.
## Usage: Basic
```ts
import { packedResourceManager } from "@suaveplan/packer-browser/singleton";
const blobUrl = await packedResourceManager.load("/banner.packed");
img.src = blobUrl;
```
## Usage: Advanced
```ts
// In a tab that already loaded the singleton elsewhere, re-importing
// returns the same instance and any registered listeners still fire.
import { packedResourceManager } from "@suaveplan/packer-browser/singleton";
const seen = new Set<string>();
packedResourceManager.on("cacheHit", ({ url }) => seen.add(url));
await packedResourceManager.load("/a.packed");
await packedResourceManager.load("/a.packed");
// seen now contains "/a.packed" — both calls observed the same
// instance and its in-memory cache.
```
## API Reference
- `packedResourceManager: PackedResourceManager` — module-singleton
instance constructed via
`createPackedResourceManager()` with no options.
## Implementation Notes
The construction call runs at import time, which means importing the
subpath has a side effect: instantiating the cache, the loader, and
the no-op OTel bundle. Packages declaring `sideEffects: false` are
still tree-shake-safe at the consumer because the side effect is
contained to this one module — bundlers (rollup, esbuild) drop the
entire file when no symbol is imported from it. Adopters who need a
zero-side-effect import are expected to use
`createPackedResourceManager` from the default entry instead.

View File

@@ -0,0 +1,91 @@
# `cross-origin` — multi-origin Service Worker registration
## Purpose
`registerCrossOriginPackedResourceServiceWorker` lets an application
register a packed-resource Service Worker hosted on a different origin
(typically a CDN subdomain). Before any browser request is dispatched
the helper validates the SW URL's origin against a caller-supplied
allowlist, so attacker-controlled or misconfigured CDN origins cannot
slip past. On success the helper returns both the new registration and
the container's current registration list so multi-tenant apps can
confirm the CDN-origin SW is actually controlling the page.
## Features
- Strict allowlist validation BEFORE `register()` fires
- Parses `swUrl` via the `URL` constructor — malformed URLs reject with
`PACKER_BROWSER.UNAUTHORIZED_ORIGIN`
- Delegates to `registerPackedResourceServiceWorker` so all the
underlying contract (AbortSignal, error wrapping, message resolution)
remains intact
- Returns the full registration list (parity with
`clients.matchAll({ includeUncontrolled: true })`)
## Usage: Basic
```ts
import { registerCrossOriginPackedResourceServiceWorker } from "@suaveplan/packer-browser/utils";
await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/packed/",
allowedOrigins: ["https://cdn.example.com"],
});
```
## Usage: Advanced
```ts
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
registerCrossOriginPackedResourceServiceWorker,
} from "@suaveplan/packer-browser";
try {
const { registration, registrations } =
await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: [
"https://cdn.example.com",
"https://cdn-fallback.example.com",
],
container: navigator.serviceWorker,
signal: shutdownController.signal,
});
console.log("registered", registration.scope, "of", registrations.length);
} catch (err) {
if (
err instanceof PackerBrowserError &&
err.code === PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN
) {
console.warn("Origin not allowed; falling back to first-party SW.");
}
}
```
## API Reference
- `registerCrossOriginPackedResourceServiceWorker(opts: RegisterCrossOriginOptions): Promise<RegisterCrossOriginResult>`
- `opts.swUrl: string` — absolute SW URL. Required.
- `opts.scope: string` — registration scope. Required.
- `opts.allowedOrigins: ReadonlyArray<string>` — exact-string match. Required.
- `opts.container?` — injectable container. Default: `navigator.serviceWorker`.
- `opts.messageResolver?` — i18n resolver.
- `opts.signal?` — AbortSignal forwarded to `register()`.
- Returns `{ registration, registrations }`.
## Implementation Notes
- Origin equality is exact-string — there is no host-suffix matching.
Allowlisting both `https://cdn.example.com` and
`https://cdn-fallback.example.com` is explicit by design.
- Malformed `swUrl` strings (relative paths, plain identifiers) yield
`UNAUTHORIZED_ORIGIN`, not `SW_REGISTRATION_FAILED`, because the
helper deliberately rejects before consulting the container.
- The `registrations` list is the full `container.getRegistrations()`
return value when a container is supplied; without one (e.g. in a
Node smoke harness) the underlying `register()` already rejects with
`SW_REGISTRATION_FAILED` so the caller never sees a partial list.

View File

@@ -0,0 +1,95 @@
# `register-service-worker`
## Purpose
`register-service-worker.ts` collects four lifecycle helpers exposed via
the `/utils` subpath: `registerPackedResourceServiceWorker`,
`unregisterPackedResourceServiceWorker`, `clearServiceWorkerCache`, and
`getServiceWorkerCacheStatus`. They wrap the
`navigator.serviceWorker` API with typed errors, injectable container
support (for tests), and `AbortSignal` honoured at every yield. Every
boundary error uses `MessageResolver`-keyed messages rather than
hard-coded English literals.
## Features
- Injectable `container: ServiceWorkerContainerLike` — tests pass an
in-memory implementation; production defaults to
`navigator.serviceWorker`.
- `AbortSignal` honoured pre-call (synchronous reject with
`PackerBrowserError(ABORTED)`) and mid-call (channel teardown +
reject) on both `clearServiceWorkerCache` and
`getServiceWorkerCacheStatus`.
- `getServiceWorkerCacheStatus` includes a hard 5-second timeout so a
hung controller cannot leak a pending Promise.
- Boundary errors:
`SW_REGISTRATION_FAILED` (no SW support, register rejected, no
controller attached), `LOAD_FAILED` (timeout / negative reply),
`ABORTED` (signal fires).
- Returns typed shapes: `ServiceWorkerRegistration`, `boolean`,
`void`, `CacheStatusReply`.
## Usage: Basic
```ts
import {
clearServiceWorkerCache,
getServiceWorkerCacheStatus,
registerPackedResourceServiceWorker,
} from "@suaveplan/packer-browser/utils";
await registerPackedResourceServiceWorker();
const status = await getServiceWorkerCacheStatus();
console.log(status.cacheSize, status.urls);
await clearServiceWorkerCache();
```
## Usage: Advanced
```ts
import type { ServiceWorkerContainerLike } from "@suaveplan/packer-browser/utils";
const ac = new AbortController();
const recorded: string[] = [];
const container: ServiceWorkerContainerLike = {
controller: createMockController(recorded),
register: async () => fakeRegistration(),
getRegistrations: async () => [],
ready: Promise.resolve(fakeRegistration()),
};
await registerPackedResourceServiceWorker({
container,
scriptUrl: "/custom.js",
signal: ac.signal,
});
```
## API Reference
- `registerPackedResourceServiceWorker(opts?: RegisterOptions):
Promise<ServiceWorkerRegistration>` —
- throws `SW_REGISTRATION_FAILED` for no SW support / rejected
`register()`.
- throws `ABORTED` on a pre-aborted signal.
- `unregisterPackedResourceServiceWorker(opts?): Promise<boolean>` —
resolves `false` when no container is available.
- `clearServiceWorkerCache(opts?): Promise<void>` — throws
`SW_REGISTRATION_FAILED` if no controller is attached; throws
`ABORTED` if the signal fires.
- `getServiceWorkerCacheStatus(opts?): Promise<CacheStatusReply>` —
throws `LOAD_FAILED` on 5 s timeout; throws `ABORTED` on signal.
- `ServiceWorkerContainerLike` / `RegisterOptions` /
`ServiceWorkerLifecycleOptions` — type-only injection points.
## Implementation Notes
The helpers route every `controller.postMessage` through a
`MessageChannel` and listen on `port1` for the reply. The
`AbortSignal` integration closes `port1` before rejecting so the
underlying SW does not see a half-open channel. The 5-second
`getServiceWorkerCacheStatus` timeout is hard-coded because the
upstream form had the same value; Cluster B will widen this once the
SW gains range-request support and a single status query can take
arbitrarily long under load.