first commit
This commit is contained in:
@@ -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.
|
||||
104
docs/foundation/data/packer-browser/src/cache/cache.md
vendored
Normal file
104
docs/foundation/data/packer-browser/src/cache/cache.md
vendored
Normal 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()`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
76
docs/foundation/data/packer-browser/src/flags/flags.md
Normal file
76
docs/foundation/data/packer-browser/src/flags/flags.md
Normal 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.
|
||||
91
docs/foundation/data/packer-browser/src/i18n/messages.md
Normal file
91
docs/foundation/data/packer-browser/src/i18n/messages.md
Normal 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.
|
||||
98
docs/foundation/data/packer-browser/src/keyring/keyring.md
Normal file
98
docs/foundation/data/packer-browser/src/keyring/keyring.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
102
docs/foundation/data/packer-browser/src/loader/loader.md
Normal file
102
docs/foundation/data/packer-browser/src/loader/loader.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
105
docs/foundation/data/packer-browser/src/manager/manager.md
Normal file
105
docs/foundation/data/packer-browser/src/manager/manager.md
Normal 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.
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
103
docs/foundation/data/packer-browser/src/prefetch/prefetch.md
Normal file
103
docs/foundation/data/packer-browser/src/prefetch/prefetch.md
Normal 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.
|
||||
88
docs/foundation/data/packer-browser/src/preload/preload.md
Normal file
88
docs/foundation/data/packer-browser/src/preload/preload.md
Normal 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.
|
||||
@@ -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.
|
||||
79
docs/foundation/data/packer-browser/src/runtime/runtime.md
Normal file
79
docs/foundation/data/packer-browser/src/runtime/runtime.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user