Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
# @suaveplan/packer-browser - Browser runtime for @suaveplan/packer — Service Worker interceptor, PackedResourceManager (LRU + Blob-URL bookkeeping), signed-manifest verification, OTel metrics, AbortSignal-aware loader. No React.
Browser-tier runtime for `@suaveplan/packer` archives. Ships a Service Worker
interceptor, a `PackedResourceManager` (LRU + Blob-URL bookkeeping +
ref-count + OTel metrics), Ed25519 archive-signature verification, and
lifecycle helpers for registering / clearing / inspecting the SW cache. No
React runtime dependency — React hooks land in the companion
`@suaveplan/react-packer` package.
<!-- begin:auto-docs -->
## Documentation tree
- [adaptive-cache](./src/adaptive-cache/adaptive-cache.md) — `createAdaptiveCacheSizer` periodically resizes a `PackedResourceCache` from a `navigator.storage.estimate()`-style quo…
- [adaptive-cache](./src/adaptive-cache/adaptive-cache.md) — `createAdaptiveCacheSizer` periodically resizes a `PackedResourceCache` from a `navigator.storage.estimate()`-style quo…
- [cache](./src/cache/cache.md) — `cache.ts` implements `PackedResourceCache` — an LRU + ref-count cache that owns the Blob-URL lifecycle for every unpac…
- [cache](./src/cache/cache.md) — `cache.ts` implements `PackedResourceCache` — an LRU + ref-count cache that owns the Blob-URL lifecycle for every unpac…
- [content-address](./src/content-address/content-address.md) — Content-addressed URL builder and cache-invalidation helpers for packed archives. Each archive's canonical URL is deriv…
- [content-address](./src/content-address/content-address.md) — Content-addressed URL builder and cache-invalidation helpers for packed archives. Each archive's canonical URL is deriv…
- [diagnostics](./src/diagnostics/diagnostics.md) — `createDiagnosticsOverlay` produces a development-time overlay that displays live cache statistics and a tail of recent…
- [diagnostics](./src/diagnostics/diagnostics.md) — `createDiagnosticsOverlay` produces a development-time overlay that displays live cache statistics and a tail of recent…
- [encrypted-archive](./src/encrypted-archive/encrypted-archive.md) — Wire format for AES-256-GCM-encrypted packed archives served alongside unencrypted bundles. Adopts the stable magic pre…
- [encrypted-archive](./src/encrypted-archive/encrypted-archive.md) — Wire format for AES-256-GCM-encrypted packed archives served alongside unencrypted bundles. Adopts the stable magic pre…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error class and stable code emitted by `@suaveplan/packer-brow…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error class and stable code emitted by `@suaveplan/packer-brow…
- [flags](./src/flags/flags.md) — `flags.ts` resolves the `@suaveplan/feature-flags` `FlagProvider` used by `PackedResourceManager` to gate Cluster B / C…
- [flags](./src/flags/flags.md) — `flags.ts` resolves the `@suaveplan/feature-flags` `FlagProvider` used by `PackedResourceManager` to gate Cluster B / C…
- [i18n](./src/i18n/messages.md) — `messages.ts` is the English defaults catalog plus the resolver hook for every user-visible error message thrown by `@s…
- [messages](./src/i18n/messages.md) — `messages.ts` is the English defaults catalog plus the resolver hook for every user-visible error message thrown by `@s…
- [keyring](./src/keyring/keyring.md) — Defines the `KeyringProvider` contract for resolving symmetric AES-256-GCM keys at decrypt time. The contract is intent…
- [keyring](./src/keyring/keyring.md) — Defines the `KeyringProvider` contract for resolving symmetric AES-256-GCM keys at decrypt time. The contract is intent…
- [loader](./src/loader/loader.md) — `loader.ts` is the fetch → verify → unpack → Blob-URL pipeline for packed resources. It is the only place in the packag…
- [loader-helpers](./src/loader/loader-helpers.md) — Small free helpers extracted from `loader.ts` so the class stays under the 300-line hard cap. These functions carry no…
- [loader-stream](./src/loader/loader-stream.md) — Progressive entry-by-entry unpack helper that powers `PackedResourceLoader.loadStream`. Callers pass a list of packed-a…
- [loader](./src/loader/loader.md) — `loader.ts` is the fetch → verify → unpack → Blob-URL pipeline for packed resources. It is the only place in the packag…
- [manager](./src/manager/manager.md) — `manager.ts` is the lifecycle façade that consumers interact with. It composes a `PackedResourceCache` and a `PackedRes…
- [manager-helpers](./src/manager/manager-helpers.md) — Small free helpers extracted from `manager.ts` to keep the class file under CLAUDE.md §3's 300-line hard cap. Both help…
- [manager-stream](./src/manager/manager-stream.md) — Free-function implementation of `PackedResourceManager.loadStream`, factored out of `manager.ts` so the class stays und…
- [manager](./src/manager/manager.md) — `manager.ts` is the lifecycle façade that consumers interact with. It composes a `PackedResourceCache` and a `PackedRes…
- [tiny-emitter](./src/manager/tiny-emitter.md) — Minimal generic typed event emitter used internally by `PackedResourceManager`. Factored out of `manager.ts` so the cla…
- [observability](./src/observability/observability.md) — `observability.ts` creates the OpenTelemetry instrument bundle for `@suaveplan/packer-browser`. The package emits four…
- [observability](./src/observability/observability.md) — `observability.ts` creates the OpenTelemetry instrument bundle for `@suaveplan/packer-browser`. The package emits four…
- [prefetch](./src/prefetch/prefetch.md) — The `prefetch` module wraps the Chromium `BackgroundFetchManager` API so an application can preload an archive on idle…
- [prefetch](./src/prefetch/prefetch.md) — The `prefetch` module wraps the Chromium `BackgroundFetchManager` API so an application can preload an archive on idle…
- [preload](./src/preload/preload.md) — `generatePreloadHints` derives an ordered list of `<link rel="preload">` hints from a packed manifest, and `injectPrelo…
- [preload](./src/preload/preload.md) — `generatePreloadHints` derives an ordered list of `<link rel="preload">` hints from a packed manifest, and `injectPrelo…
- [range-response](./src/range-response/range-response.md) — RFC 7233 §2.1 single-range `Response` builder for packed-resource delivery from the Service Worker. Browsers ask for ra…
- [range-response](./src/range-response/range-response.md) — RFC 7233 §2.1 single-range `Response` builder for packed-resource delivery from the Service Worker. Browsers ask for ra…
- [runtime](./src/runtime/runtime.md) — `runtime.ts` centralises the three injectable platform primitives (`Clock`, `MonotonicClock`, `Rng`) that every product…
- [runtime](./src/runtime/runtime.md) — `runtime.ts` centralises the three injectable platform primitives (`Clock`, `MonotonicClock`, `Rng`) that every product…
- [service-worker](./src/service-worker/service-worker.md) — `service-worker.ts` is the production Service Worker entrypoint exposed via the `/service-worker` subpath. Its only pub…
- [service-worker](./src/service-worker/service-worker.md) — `service-worker.ts` is the production Service Worker entrypoint exposed via the `/service-worker` subpath. Its only pub…
- [sw-shim](./src/service-worker/sw-shim.md) — `sw-shim.ts` provides `createServiceWorkerShim()`, a hand-rolled in-memory implementation of `ServiceWorkerGlobalScope`…
- [signature](./src/signature/signature.md) — `signature.ts` is the Ed25519 signature-verification gate run by the loader before any packed bytes are handed to `Unpa…
- [signature](./src/signature/signature.md) — `signature.ts` is the Ed25519 signature-verification gate run by the loader before any packed bytes are handed to `Unpa…
- [singleton](./src/singleton/singleton.md) — `singleton.ts` exposes the module-level shared `PackedResourceManager` instance behind the `@suaveplan/packer-browser/s…
- [singleton](./src/singleton/singleton.md) — `singleton.ts` exposes the module-level shared `PackedResourceManager` instance behind the `@suaveplan/packer-browser/s…
- [cross-origin](./src/utils/cross-origin.md) — `registerCrossOriginPackedResourceServiceWorker` lets an application register a packed-resource Service Worker hosted o…
- [register-service-worker](./src/utils/register-service-worker.md) — `register-service-worker.ts` collects four lifecycle helpers exposed via the `/utils` subpath: `registerPackedResourceS…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/packer-browser
```
## Quick start
```ts
import {
createPackedResourceManager,
registerPackedResourceServiceWorker,
} from "@suaveplan/packer-browser";
await registerPackedResourceServiceWorker({ scriptUrl: "/sw.js" });
const manager = createPackedResourceManager();
const blobUrl = await manager.load("/banner.packed");
imgEl.src = blobUrl;
```
For a global shared instance, import from the `/singleton` subpath:
```ts
import { packedResourceManager } from "@suaveplan/packer-browser/singleton";
const url = await packedResourceManager.load("/banner.packed");
```
## Modules
- [`error-codes`](./src/error-codes.md) — `PACKER_BROWSER_CODES` + `PackerBrowserError`.
- [`i18n/messages`](./src/i18n/messages.md) — `MessageResolver` catalog.
- [`runtime/runtime`](./src/runtime/runtime.md) — injectable `Clock` / `Rng`.
- [`observability/observability`](./src/observability/observability.md) — OTel counters + histogram.
- [`flags/flags`](./src/flags/flags.md) — `@suaveplan/feature-flags` slot.
- [`signature/signature`](./src/signature/signature.md) — `verifyArchiveSignature` (Ed25519).
- [`cache/cache`](./src/cache/cache.md) — `PackedResourceCache` LRU + ref-count.
- [`loader/loader`](./src/loader/loader.md) — `PackedResourceLoader` fetch / verify / unpack.
- [`manager/manager`](./src/manager/manager.md) — `PackedResourceManager` lifecycle façade + factory.
- [`service-worker/service-worker`](./src/service-worker/service-worker.md) — production SW entrypoint.
- [`service-worker/sw-shim`](./src/service-worker/sw-shim.md) — hand-rolled `ServiceWorkerGlobalScope` shim for unit tests.
- [`utils/register-service-worker`](./src/utils/register-service-worker.md) — SW lifecycle helpers.
- [`singleton`](./src/singleton.md) — module-singleton hosted at `/singleton` subpath.
## Subpath exports
- `.` — full default surface (excludes the module singleton).
- `./manager` — `PackedResourceManager`, factory, types.
- `./service-worker` — `runPackedResourceServiceWorker` + `createServiceWorkerShim`.
- `./utils` — `register` / `unregister` / `clearServiceWorkerCache` / `getServiceWorkerCacheStatus`.
- `./singleton` — eagerly-constructed shared `packedResourceManager`.
## Scope
Cluster A delivers the production-quality core: manager split, OTel
metrics, signed-manifest verification, `AbortSignal` propagation,
injected `Clock`/`Rng`, retry on the source-pull, feature-flag slot,
unit-tested SW with a real shim, and the five §3 subpath exports.
Cluster B and C add progressive streaming, range-request SW, adaptive
cache sizing, encrypted-archive keyring, content-addressed versioned
URLs, BackgroundFetch, multi-origin SW helpers, and the diagnostics
overlay. See the design proposal for
the full out-of-scope list.
## License
MIT.

View File

@@ -0,0 +1,137 @@
{
"name": "@suaveplan/packer-browser",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Browser runtime for @suaveplan/packer — Service Worker interceptor, PackedResourceManager (LRU + Blob-URL bookkeeping), signed-manifest verification, OTel metrics, AbortSignal-aware loader. No React.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./manager": {
"import": {
"types": "./dist/manager/index.d.ts",
"default": "./dist/manager/index.js"
},
"require": {
"types": "./dist/manager/index.d.ts",
"default": "./dist/manager/index.js"
}
},
"./service-worker": {
"import": {
"types": "./dist/service-worker/index.d.ts",
"default": "./dist/service-worker/index.js"
},
"require": {
"types": "./dist/service-worker/index.d.ts",
"default": "./dist/service-worker/index.js"
}
},
"./utils": {
"import": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
},
"require": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
}
},
"./singleton": {
"import": {
"types": "./dist/singleton/index.d.ts",
"default": "./dist/singleton/index.js"
},
"require": {
"types": "./dist/singleton/index.d.ts",
"default": "./dist/singleton/index.js"
}
},
"./diagnostics": {
"import": {
"types": "./dist/diagnostics/index.d.ts",
"default": "./dist/diagnostics/index.js"
},
"require": {
"types": "./dist/diagnostics/index.d.ts",
"default": "./dist/diagnostics/index.js"
}
},
"./prefetch": {
"import": {
"types": "./dist/prefetch/index.d.ts",
"default": "./dist/prefetch/index.js"
},
"require": {
"types": "./dist/prefetch/index.d.ts",
"default": "./dist/prefetch/index.js"
}
},
"./preload": {
"import": {
"types": "./dist/preload/index.d.ts",
"default": "./dist/preload/index.js"
},
"require": {
"types": "./dist/preload/index.d.ts",
"default": "./dist/preload/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:e2e": "playwright test",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/feature-flags": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/packer": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@opentelemetry/sdk-trace-base": "catalog:observability",
"@playwright/test": "catalog:testing",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-dom": "workspace:^",
"@suaveplan/testing-e2e": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright config for the `@suaveplan/packer-browser` Service Worker
* interception E2E suite. The suite is intentionally minimal in Cluster A —
* one happy-path scenario covering SW registration + packed-URL interception.
* The broader multi-browser sweep lands in Cluster C alongside diagnostics.
*/
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

View File

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

View File

@@ -0,0 +1,329 @@
import type { PackedDataMetadata } from "@suaveplan/packer";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PackedResourceCache } from "../cache/cache.js";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { resolveRuntime } from "../runtime/runtime.js";
import { createAdaptiveCacheSizer } from "./adaptive-cache.js";
function makeCache(maxCacheSize = 100): PackedResourceCache {
return new PackedResourceCache({
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
maxCacheSize,
});
}
function fakeMetadata(): PackedDataMetadata {
return {
originalSize: 1,
compressedSize: 1,
encrypted: false,
timestamp: 0,
checksum: "",
};
}
function manualInterval(): {
impl: (handler: () => void, intervalMs: number) => () => void;
tick: () => void;
intervalMs: number | undefined;
} {
let stored: (() => void) | undefined;
let storedMs: number | undefined;
return {
impl: (handler, intervalMs) => {
stored = handler;
storedMs = intervalMs;
return () => {
stored = undefined;
};
},
tick: () => stored?.(),
get intervalMs(): number | undefined {
return storedMs;
},
};
}
describe("createAdaptiveCacheSizer", () => {
it("applies the configured fraction of the estimated quota to the cache ceiling", {
timeout: 30_000,
}, async () => {
const cache = makeCache(100);
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.25,
storageEstimate: async () => ({ quota: 4000, usage: 0 }),
setIntervalImpl: timer.impl,
});
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(1000);
expect(sizer.getLastApplied()).toBe(1000);
sizer.dispose();
});
it("falls back to the static ceiling when navigator.storage.estimate is unavailable", {
timeout: 5000,
}, async () => {
const cache = makeCache(42);
// Force missing API: no injection, and ensure global navigator
// is either absent or lacks `storage.estimate`. happy-dom's
// `navigator.storage` is not present by default.
const sizer = createAdaptiveCacheSizer({ cache });
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(42);
expect(sizer.getLastApplied()).toBeUndefined();
sizer.dispose();
});
it("AbortSignal cancellation terminates the recurring updater", {
timeout: 30_000,
}, async () => {
const cache = makeCache(100);
let calls = 0;
const controller = new AbortController();
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.5,
storageEstimate: async () => {
calls += 1;
return { quota: 2000 };
},
signal: controller.signal,
setIntervalImpl: timer.impl,
});
await sizer.refresh();
expect(calls).toBe(2); // initial fire + manual refresh
controller.abort();
timer.tick();
timer.tick();
await sizer.refresh();
expect(calls).toBe(2);
sizer.dispose();
});
it("disposer returned from the sizer stops further estimate calls", {
timeout: 5000,
}, async () => {
const cache = makeCache(100);
let calls = 0;
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
storageEstimate: async () => {
calls += 1;
return { quota: 1000 };
},
setIntervalImpl: timer.impl,
});
await sizer.refresh();
const seen = calls;
sizer.dispose();
timer.tick();
timer.tick();
await sizer.refresh();
expect(calls).toBe(seen);
});
it("pre-aborted signal returns a sizer that never schedules work", {
timeout: 5000,
}, async () => {
const cache = makeCache(50);
const controller = new AbortController();
controller.abort();
const timer = manualInterval();
let calls = 0;
const sizer = createAdaptiveCacheSizer({
cache,
signal: controller.signal,
storageEstimate: async () => {
calls += 1;
return { quota: 1000 };
},
setIntervalImpl: timer.impl,
});
expect(timer.intervalMs).toBeUndefined();
await sizer.refresh();
expect(calls).toBe(0);
expect(cache.getMaxCacheSize()).toBe(50);
expect(sizer.getLastApplied()).toBeUndefined();
sizer.dispose();
});
it("ignores quota reports without a positive quota field", {
timeout: 5000,
}, async () => {
const cache = makeCache(60);
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
storageEstimate: async () => ({}),
setIntervalImpl: timer.impl,
});
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(60);
sizer.dispose();
});
it("clamps an out-of-range fraction back to the default", {
timeout: 5000,
}, async () => {
const cache = makeCache(100);
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 2,
storageEstimate: async () => ({ quota: 100 }),
setIntervalImpl: timer.impl,
});
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(100); // clamp to 1.0
sizer.dispose();
const sizer2 = createAdaptiveCacheSizer({
cache,
fraction: -1,
storageEstimate: async () => ({ quota: 100 }),
setIntervalImpl: timer.impl,
});
await sizer2.refresh();
expect(cache.getMaxCacheSize()).toBe(25); // default 0.25
sizer2.dispose();
});
it("running the interval handler refreshes the ceiling from a new estimate", {
timeout: 5000,
}, async () => {
const cache = makeCache(100);
const timer = manualInterval();
let quota = 4000;
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.25,
storageEstimate: async () => ({ quota }),
setIntervalImpl: timer.impl,
});
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(1000);
quota = 8000;
timer.tick();
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(2000);
sizer.dispose();
});
it("dispose during a pending refresh leaves the ceiling unchanged", {
timeout: 5000,
}, async () => {
const cache = makeCache(100);
const timer = manualInterval();
let release: ((v: { quota: number }) => void) | undefined;
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.25,
storageEstimate: () =>
new Promise((res) => {
release = res;
}),
setIntervalImpl: timer.impl,
});
sizer.dispose();
release?.({ quota: 4000 });
await new Promise((res) => setTimeout(res, 0));
expect(cache.getMaxCacheSize()).toBe(100);
});
it("abort fired between estimate resolve and apply short-circuits the update", {
timeout: 5000,
}, async () => {
const cache = makeCache(100);
const controller = new AbortController();
const timer = manualInterval();
let release: ((v: { quota: number }) => void) | undefined;
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.25,
storageEstimate: () =>
new Promise((res) => {
release = res;
}),
signal: controller.signal,
setIntervalImpl: timer.impl,
});
controller.abort();
release?.({ quota: 4000 });
await new Promise((res) => setTimeout(res, 0));
expect(cache.getMaxCacheSize()).toBe(100);
sizer.dispose();
});
it("default setInterval impl unrefs the timer and disposes cleanly", {
timeout: 5000,
}, async () => {
const cache = makeCache(50);
const sizer = createAdaptiveCacheSizer({
cache,
storageEstimate: async () => ({ quota: 200 }),
intervalMs: 10_000,
});
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBeGreaterThanOrEqual(1);
sizer.dispose();
});
it("uses navigator.storage.estimate when present and no injection is supplied", {
timeout: 5000,
}, async () => {
const cache = makeCache(100);
const originalNavigator = (globalThis as { navigator?: unknown })
.navigator;
(globalThis as { navigator?: unknown }).navigator = {
storage: {
estimate: async () => ({ quota: 2000, usage: 0 }),
},
};
try {
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.5,
setIntervalImpl: timer.impl,
});
await sizer.refresh();
expect(cache.getMaxCacheSize()).toBe(1000);
sizer.dispose();
} finally {
(globalThis as { navigator?: unknown }).navigator =
originalNavigator;
}
});
it("setMaxCacheSize via the sizer evicts entries beyond the new ceiling", {
timeout: 5000,
}, async () => {
const cache = new PackedResourceCache({
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
maxCacheSize: 10,
});
cache.insert("a", new Blob(["1"]), "blob:a", fakeMetadata());
cache.release("a");
cache.insert("b", new Blob(["2"]), "blob:b", fakeMetadata());
cache.release("b");
cache.insert("c", new Blob(["3"]), "blob:c", fakeMetadata());
cache.release("c");
const timer = manualInterval();
const sizer = createAdaptiveCacheSizer({
cache,
fraction: 0.001, // tiny fraction
storageEstimate: async () => ({ quota: 2000 }),
setIntervalImpl: timer.impl,
});
await sizer.refresh();
// fraction*quota = 2 → ceiling 2 → evicts 1 entry
expect(cache.size()).toBe(2);
sizer.dispose();
});
});

View File

@@ -0,0 +1,123 @@
import type {
AdaptiveCacheSizer,
AdaptiveCacheSizerOptions,
StorageEstimateFn,
} from "./adaptive-cache.types.js";
const DEFAULT_FRACTION = 0.25;
const DEFAULT_INTERVAL_MS = 60_000;
function resolveStorageEstimate(
injected: StorageEstimateFn | undefined
): StorageEstimateFn | undefined {
if (injected !== undefined) return injected;
if (
typeof navigator !== "undefined" &&
navigator.storage !== undefined &&
typeof navigator.storage.estimate === "function"
) {
return () => navigator.storage.estimate();
}
return undefined;
}
function clampFraction(f: number | undefined): number {
const v = f ?? DEFAULT_FRACTION;
if (!Number.isFinite(v) || v <= 0) return DEFAULT_FRACTION;
if (v > 1) return 1;
return v;
}
function defaultSetInterval(
handler: () => void,
intervalMs: number
): () => void {
const id = setInterval(handler, intervalMs);
// Avoid keeping a Node test process alive on the recurring updater.
// `unref` is available on Node/Bun timers but not on browsers; the
// optional chain handles both worlds.
(id as unknown as { unref?: () => void }).unref?.();
return () => clearInterval(id);
}
/**
* Periodically resize a {@link PackedResourceCache} from a
* `navigator.storage.estimate()`-style quota report. The sizer kicks off
* an initial estimate on construction and then refreshes on the configured
* interval. When the platform lacks the storage-estimate API AND no
* injected `storageEstimate` is supplied, the sizer is a safe no-op:
* the cache's static ceiling is preserved and no timer is scheduled.
*
* The sizer honours an injected `AbortSignal`: a pre-aborted signal
* causes the constructor to return a disposer-only sizer that never
* runs work; aborting after construction cancels the recurring updater
* and short-circuits any pending refresh.
*/
export function createAdaptiveCacheSizer(
opts: AdaptiveCacheSizerOptions
): AdaptiveCacheSizer {
const fraction = clampFraction(opts.fraction);
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
const estimate = resolveStorageEstimate(opts.storageEstimate);
const setIntervalImpl = opts.setIntervalImpl ?? defaultSetInterval;
const signal = opts.signal;
let lastApplied: number | undefined;
let stopped = false;
let stopTimer: (() => void) | undefined;
const dispose = (): void => {
if (stopped) return;
stopped = true;
if (stopTimer !== undefined) stopTimer();
};
if (estimate === undefined) {
return {
dispose,
async refresh() {
// No API and no injection — intentionally a no-op.
},
getLastApplied: () => lastApplied,
};
}
const refresh = async (): Promise<void> => {
if (stopped) return;
const report = await estimate();
if (stopped) return;
const quota = report.quota;
if (typeof quota !== "number" || quota <= 0) return;
const next = Math.max(1, Math.floor(quota * fraction));
opts.cache.setMaxCacheSize(next);
lastApplied = next;
};
if (signal?.aborted === true) {
// Honour pre-aborted signals — no work scheduled.
stopped = true;
return { dispose, refresh, getLastApplied: () => lastApplied };
}
const onAbort = (): void => {
dispose();
};
signal?.addEventListener("abort", onAbort, { once: true });
// Fire-and-forget the initial estimate; surface failures by letting
// the underlying promise reject — callers can `await sizer.refresh()`
// to observe errors deterministically.
void refresh();
stopTimer = setIntervalImpl(() => {
void refresh();
}, intervalMs);
return {
dispose: () => {
signal?.removeEventListener("abort", onAbort);
dispose();
},
refresh,
getLastApplied: () => lastApplied,
};
}

View File

@@ -0,0 +1,82 @@
import type { PackedResourceCache } from "../cache/cache.js";
/**
* `navigator.storage.estimate()`-shaped quota report. The MDN type uses
* `quota` + `usage` so the sizer mirrors that exactly.
*/
export interface StorageEstimateReport {
/** Total bytes available to the origin. May be undefined. */
readonly quota?: number;
/** Bytes currently consumed by the origin. May be undefined. */
readonly usage?: number;
}
/** Function that resolves the current storage estimate. */
export type StorageEstimateFn = () => Promise<StorageEstimateReport>;
/**
* Options accepted by {@link createAdaptiveCacheSizer}. Every field is
* optional; defaults are documented below.
*/
export interface AdaptiveCacheSizerOptions {
/** Cache to size. Required. */
readonly cache: PackedResourceCache;
/**
* Fraction of the reported `quota` to use as the ceiling. Default
* `0.25` (25 %). Clamped to (0, 1].
*/
readonly fraction?: number;
/**
* Recurring interval in ms between estimate refreshes. Default
* `60_000`. The first estimate is run synchronously on construction.
*/
readonly intervalMs?: number;
/**
* Storage-estimate injection. Defaults to
* `() => navigator.storage.estimate()` when available; when missing,
* the sizer is a no-op.
*/
readonly storageEstimate?: StorageEstimateFn;
/** Cancellation signal — stops the recurring updater. */
readonly signal?: AbortSignal;
/**
* Timer injection used in tests. Receives `(handler, intervalMs)` and
* returns a disposer. Defaults to `setInterval` / `clearInterval`.
*/
readonly setIntervalImpl?: (
handler: () => void,
intervalMs: number
) => () => void;
}
/** Disposer + introspection surface returned by {@link createAdaptiveCacheSizer}. */
export interface AdaptiveCacheSizer {
/** Stop the recurring updater. Idempotent. */
dispose(): void;
/**
* Manually refresh the cache ceiling from a one-shot estimate.
* Resolves once the cache has been updated (or skipped when no API
* is available).
*/
refresh(): Promise<void>;
/** Most recent ceiling applied to the cache, or `undefined` before the first refresh. */
getLastApplied(): number | undefined;
}
/**
* `AdaptiveCacheOptions` is the bundle accepted by
* `PackedResourceManagerOptions.adaptiveCache`. It mirrors the
* top-level sizer options but defers `cache` to the manager.
*/
export interface AdaptiveCacheOptions {
/** Default `true`. Set `false` to skip sizer construction entirely. */
readonly enabled?: boolean;
readonly fraction?: number;
readonly intervalMs?: number;
readonly storageEstimate?: StorageEstimateFn;
readonly signal?: AbortSignal;
readonly setIntervalImpl?: (
handler: () => void,
intervalMs: number
) => () => void;
}

View File

@@ -0,0 +1,8 @@
export { createAdaptiveCacheSizer } from "./adaptive-cache.js";
export type {
AdaptiveCacheOptions,
AdaptiveCacheSizer,
AdaptiveCacheSizerOptions,
StorageEstimateFn,
StorageEstimateReport,
} from "./adaptive-cache.types.js";

View File

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

View File

@@ -0,0 +1,294 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import type { PackedDataMetadata } from "@suaveplan/packer";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { resolveRuntime } from "../runtime/runtime.js";
import { PackedResourceCache } from "./cache.js";
function freshMetadata(): PackedDataMetadata {
return {
originalSize: 4,
compressedSize: 4,
encrypted: false,
timestamp: 0,
checksum: "",
};
}
function fakeBlobUrl(seed: number): string {
return `blob:packer-browser/${seed}`;
}
function makeCache(opts: {
clock?: () => number;
maxCacheSize?: number;
maxCacheAge?: number;
revoked?: string[];
}): PackedResourceCache {
const clockFn = opts.clock ?? ((): number => 0);
const observability = createPackerBrowserObservability();
const runtime = resolveRuntime({ clock: clockFn });
const cacheOpts: ConstructorParameters<typeof PackedResourceCache>[0] = {
runtime,
observability,
};
if (opts.maxCacheSize !== undefined)
cacheOpts.maxCacheSize = opts.maxCacheSize;
if (opts.maxCacheAge !== undefined)
cacheOpts.maxCacheAge = opts.maxCacheAge;
if (opts.revoked !== undefined) {
const sink = opts.revoked;
cacheOpts.revokeObjectUrl = (url): void => {
sink.push(url);
};
}
return new PackedResourceCache(cacheOpts);
}
describe("PackedResourceCache", () => {
it("entry timestamps come from the injected Clock", {
timeout: 5000,
}, () => {
let t = 1_700_000_000_000;
const cache = makeCache({ clock: () => t });
const blob = new Blob(["a"]);
const entry = cache.insert("k", blob, fakeBlobUrl(1), freshMetadata());
expect(entry.cachedAt).toBe(1_700_000_000_000);
t = 1_700_000_005_000;
const hit = cache.get("k");
expect(hit?.cachedAt).toBe(1_700_000_005_000);
});
it("get on a miss returns undefined and records a miss", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
const observability = createPackerBrowserObservability({
meter: provider.getMeter("cache-miss"),
});
const cache = new PackedResourceCache({
runtime: resolveRuntime(),
observability,
});
expect(cache.get("absent")).toBeUndefined();
await provider.forceFlush();
const exported = exporter.getMetrics();
const names = exported
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_cache_miss_total");
});
it("increments packed_resource_cache_hits_total on a hit and packed_resource_cache_miss_total on a miss", {
timeout: 30_000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
const observability = createPackerBrowserObservability({
meter: provider.getMeter("hits"),
});
const cache = new PackedResourceCache({
runtime: resolveRuntime(),
observability,
});
expect(cache.get("nope")).toBeUndefined();
cache.insert("k", new Blob(["x"]), fakeBlobUrl(2), freshMetadata());
expect(cache.get("k")).toBeDefined();
await provider.forceFlush();
const exported = exporter.getMetrics();
const counters = exported
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics);
const hits = counters.find(
(c) => c.descriptor.name === "packed_resource_cache_hits_total"
);
const miss = counters.find(
(c) => c.descriptor.name === "packed_resource_cache_miss_total"
);
expect(hits).toBeDefined();
expect(miss).toBeDefined();
});
it("evicts the least-recently-used entry with refCount=0 when capacity is exceeded", {
timeout: 5000,
}, () => {
let t = 0;
const revoked: string[] = [];
const cache = makeCache({
clock: () => t,
maxCacheSize: 2,
revoked,
});
cache.insert("a", new Blob(["1"]), fakeBlobUrl(11), freshMetadata());
cache.release("a");
t = 1;
cache.insert("b", new Blob(["2"]), fakeBlobUrl(12), freshMetadata());
cache.release("b");
t = 2;
cache.insert("c", new Blob(["3"]), fakeBlobUrl(13), freshMetadata());
// a is oldest, should have been evicted
expect(cache.has("a")).toBe(false);
expect(cache.has("b")).toBe(true);
expect(cache.has("c")).toBe(true);
expect(revoked).toEqual([fakeBlobUrl(11)]);
});
it("skips eviction when every entry is pinned", { timeout: 5000 }, () => {
const cache = makeCache({ maxCacheSize: 1 });
cache.insert("a", new Blob(["1"]), fakeBlobUrl(21), freshMetadata());
// a has refCount=1 — not eligible
cache.insert("b", new Blob(["2"]), fakeBlobUrl(22), freshMetadata());
// evictLRU finds no eligible entry; both remain
expect(cache.has("a")).toBe(true);
expect(cache.has("b")).toBe(true);
});
it("release decrements refCount and clamps at 0", { timeout: 5000 }, () => {
const cache = makeCache({});
cache.insert("a", new Blob(["1"]), fakeBlobUrl(31), freshMetadata());
cache.release("a");
cache.release("a"); // already zero
const hit = cache.get("a");
expect(hit?.refCount).toBe(1); // get increments back to 1
});
it("release on an absent key is a no-op", { timeout: 5000 }, () => {
const cache = makeCache({});
expect(() => cache.release("nope")).not.toThrow();
});
it("cleanupExpired evicts refCount-zero entries older than maxCacheAge", {
timeout: 5000,
}, () => {
let t = 0;
const revoked: string[] = [];
const cache = makeCache({
clock: () => t,
maxCacheAge: 1000,
revoked,
});
cache.insert("a", new Blob(["1"]), fakeBlobUrl(41), freshMetadata());
cache.release("a");
cache.insert("b", new Blob(["2"]), fakeBlobUrl(42), freshMetadata());
// b still pinned
t = 5000;
const evicted = cache.cleanupExpired();
expect(evicted).toBe(1);
expect(cache.has("a")).toBe(false);
expect(cache.has("b")).toBe(true);
expect(revoked).toEqual([fakeBlobUrl(41)]);
});
it("clear evicts every entry regardless of refCount and revokes every URL", {
timeout: 5000,
}, () => {
const revoked: string[] = [];
const cache = makeCache({ revoked });
cache.insert("a", new Blob(["1"]), fakeBlobUrl(51), freshMetadata());
cache.insert("b", new Blob(["2"]), fakeBlobUrl(52), freshMetadata());
cache.clear();
expect(cache.size()).toBe(0);
expect(revoked.sort()).toEqual(
[fakeBlobUrl(51), fakeBlobUrl(52)].sort()
);
});
it("getStats returns size, maxSize, and per-entry summaries", {
timeout: 5000,
}, () => {
let t = 0;
const cache = makeCache({ clock: () => t, maxCacheSize: 4 });
cache.insert("a", new Blob(["1"]), fakeBlobUrl(61), freshMetadata());
t = 250;
const stats = cache.getStats();
expect(stats.size).toBe(1);
expect(stats.maxSize).toBe(4);
expect(stats.entries[0]?.age).toBe(250);
expect(stats.entries[0]?.refCount).toBe(1);
});
it("evictLRU returns undefined when no candidate is available", {
timeout: 5000,
}, () => {
const cache = makeCache({});
cache.insert("a", new Blob(["1"]), fakeBlobUrl(71), freshMetadata());
expect(cache.evictLRU()).toBeUndefined();
});
it("evictLRU returns the evicted key when one is dropped", {
timeout: 5000,
}, () => {
const cache = makeCache({});
cache.insert("a", new Blob(["1"]), fakeBlobUrl(81), freshMetadata());
cache.release("a");
expect(cache.evictLRU()).toBe("a");
});
it("setMaxCacheSize evicts unpinned entries until size meets the new ceiling", {
timeout: 5000,
}, () => {
const revoked: string[] = [];
const cache = makeCache({ maxCacheSize: 10, revoked });
for (let i = 0; i < 5; i++) {
cache.insert(
`k${i}`,
new Blob([String(i)]),
fakeBlobUrl(90 + i),
freshMetadata()
);
cache.release(`k${i}`);
}
expect(cache.size()).toBe(5);
cache.setMaxCacheSize(3);
expect(cache.size()).toBe(3);
expect(cache.getMaxCacheSize()).toBe(3);
expect(revoked.length).toBe(2);
});
it("setMaxCacheSize stops shrinking when every remaining entry is pinned", {
timeout: 5000,
}, () => {
const cache = makeCache({ maxCacheSize: 10 });
for (let i = 0; i < 4; i++) {
cache.insert(
`p${i}`,
new Blob([String(i)]),
fakeBlobUrl(100 + i),
freshMetadata()
);
}
// All four are pinned (refCount=1).
cache.setMaxCacheSize(1);
expect(cache.size()).toBe(4);
expect(cache.getMaxCacheSize()).toBe(1);
});
it("setMaxCacheSize clamps non-positive ceilings to 1", {
timeout: 5000,
}, () => {
const cache = makeCache({ maxCacheSize: 10 });
cache.setMaxCacheSize(0);
expect(cache.getMaxCacheSize()).toBe(1);
cache.setMaxCacheSize(Number.NaN);
expect(cache.getMaxCacheSize()).toBe(1);
});
});

View File

@@ -0,0 +1,206 @@
import type { PackedDataMetadata } from "@suaveplan/packer";
import type {
CachedResource,
CacheStats,
PackedResourceCacheOptions,
} from "./cache.types.js";
const DEFAULT_MAX_SIZE = 100;
const DEFAULT_MAX_AGE_MS = 3_600_000; // 1 hour
function defaultRevokeObjectUrl(url: string): void {
URL.revokeObjectURL(url);
}
/**
* LRU + ref-count cache for unpacked packed resources. The cache owns the
* Blob-URL lifecycle: every entry it evicts is revoked through the
* injectable {@link PackedResourceCacheOptions.revokeObjectUrl} function.
*
* The cache is OTel-instrumented — every {@link PackedResourceCache.get}
* call records exactly one of `packed_resource_cache_hits_total` or
* `packed_resource_cache_miss_total`. Wall-clock reads come from the
* injected `Clock` so deterministic tests can pin entry ages.
*
* Eviction policy: when the cache exceeds `maxCacheSize`, the
* least-recently-cached entry with `refCount === 0` is evicted. Entries
* with `refCount > 0` are pinned and never evicted — callers are
* responsible for matching every `get`/`insert` with a `release`.
*/
export class PackedResourceCache {
private readonly entries = new Map<string, CachedResource>();
private maxCacheSize: number;
private readonly maxCacheAge: number;
private readonly runtime: PackedResourceCacheOptions["runtime"];
private readonly observability: PackedResourceCacheOptions["observability"];
private readonly revokeObjectUrl: (url: string) => void;
public constructor(opts: PackedResourceCacheOptions) {
this.maxCacheSize = opts.maxCacheSize ?? DEFAULT_MAX_SIZE;
this.maxCacheAge = opts.maxCacheAge ?? DEFAULT_MAX_AGE_MS;
this.runtime = opts.runtime;
this.observability = opts.observability;
this.revokeObjectUrl = opts.revokeObjectUrl ?? defaultRevokeObjectUrl;
}
/**
* Look up an entry. On a hit, the entry's refCount is incremented and
* its `cachedAt` is refreshed (so LRU tracks last-use, not first-use).
*/
public get(key: string): CachedResource | undefined {
const entry = this.entries.get(key);
if (entry === undefined) {
this.observability.counters.cacheMissTotal.add(1);
return undefined;
}
entry.refCount += 1;
entry.cachedAt = this.runtime.clock();
this.observability.counters.cacheHitsTotal.add(1);
return entry;
}
/**
* Insert a freshly-loaded entry. The caller retains the returned
* `CachedResource` reference; `refCount` is initialised to 1.
* If the cache exceeds `maxCacheSize`, the least-recently-cached
* entry with `refCount === 0` is evicted to make room.
*/
public insert(
key: string,
blob: Blob,
url: string,
metadata: PackedDataMetadata
): CachedResource {
const entry: CachedResource = {
url,
blob,
metadata,
cachedAt: this.runtime.clock(),
refCount: 1,
};
this.entries.set(key, entry);
if (this.entries.size > this.maxCacheSize) {
this.evictLRU();
}
return entry;
}
/**
* Decrement an entry's refCount. When the count reaches 0 the entry
* becomes eligible for eviction (it is not evicted eagerly — the
* next `insert` or `cleanupExpired` call may evict it).
*/
public release(key: string): void {
const entry = this.entries.get(key);
if (entry === undefined) return;
entry.refCount = Math.max(0, entry.refCount - 1);
}
/**
* Walk the cache and evict every entry whose age (now - cachedAt)
* exceeds `maxCacheAge` AND whose refCount is zero. Returns the
* number of evicted entries.
*/
public cleanupExpired(): number {
const now = this.runtime.clock();
const toEvict: string[] = [];
for (const [key, entry] of this.entries) {
const age = now - entry.cachedAt;
if (age > this.maxCacheAge && entry.refCount === 0) {
toEvict.push(key);
}
}
for (const key of toEvict) {
this.evictKey(key);
}
return toEvict.length;
}
/**
* Evict every entry regardless of refCount. Used by `clearCache`. All
* Blob URLs are revoked.
*/
public clear(): void {
for (const [key, entry] of this.entries) {
this.revokeObjectUrl(entry.url);
this.entries.delete(key);
}
}
/** Snapshot summary of cache state. */
public getStats(): CacheStats {
const now = this.runtime.clock();
const entries: CacheStats["entries"] = Array.from(this.entries).map(
([url, entry]) => ({
url,
refCount: entry.refCount,
age: now - entry.cachedAt,
})
);
return {
size: this.entries.size,
maxSize: this.maxCacheSize,
entries,
};
}
/** Number of entries currently in the cache. */
public size(): number {
return this.entries.size;
}
/** Current cache ceiling. Mutated by the adaptive sizer. */
public getMaxCacheSize(): number {
return this.maxCacheSize;
}
/**
* Update the cache's maximum size at runtime. Used by the adaptive
* sizer (Cluster C) to mirror `navigator.storage.estimate()`. After
* updating the ceiling, runs `evictLRU()` until the live size meets
* the new bound OR no unpinned candidate remains. A non-positive
* ceiling is clamped to `1` so eviction has a well-defined target.
*/
public setMaxCacheSize(n: number): void {
const next = Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
this.maxCacheSize = next;
while (this.entries.size > this.maxCacheSize) {
const evicted = this.evictLRU();
if (evicted === undefined) return;
}
}
/** Whether the cache contains a given key. Read-only — does not affect refCount. */
public has(key: string): boolean {
return this.entries.has(key);
}
/**
* Evict the least-recently-cached entry whose refCount is zero. If no
* candidate exists (every entry is pinned), this is a no-op. Returns
* the evicted key, or `undefined` if no eviction occurred.
*/
public evictLRU(): string | undefined {
let oldestKey: string | undefined;
let oldestTime = Number.POSITIVE_INFINITY;
for (const [key, entry] of this.entries) {
if (entry.refCount === 0 && entry.cachedAt < oldestTime) {
oldestKey = key;
oldestTime = entry.cachedAt;
}
}
if (oldestKey !== undefined) {
this.evictKey(oldestKey);
}
return oldestKey;
}
private evictKey(key: string): void {
const entry = this.entries.get(key) as CachedResource;
// Callers source `key` from a fresh entries iteration, so the
// Map.get is never undefined in practice; cast preserves
// TypeScript's narrowing without an unreachable runtime guard.
this.revokeObjectUrl(entry.url);
this.entries.delete(key);
}
}

View File

@@ -0,0 +1,49 @@
import type { PackedDataMetadata } from "@suaveplan/packer";
import type { PackerBrowserObservability } from "../observability/observability.types.js";
import type { PackerBrowserRuntime } from "../runtime/runtime.types.js";
/** Options accepted by {@link PackedResourceCache}. */
export interface PackedResourceCacheOptions {
/** Maximum number of cached entries. Default: 100. */
maxCacheSize?: number;
/** Max entry age in ms; older entries with refCount=0 are evicted. Default: 1h. */
maxCacheAge?: number;
/** Resolved runtime (clock / rng). */
runtime: PackerBrowserRuntime;
/** Resolved OTel observability bundle. */
observability: PackerBrowserObservability;
/**
* Function called to revoke a Blob URL when an entry is evicted.
* Injectable so tests can observe revocations without touching the
* global `URL` object. Defaults to `URL.revokeObjectURL`.
*/
revokeObjectUrl?: (url: string) => void;
}
/**
* Cached resource entry. The `url` field is the Blob URL handed back to
* consumers; `blob` is the underlying object kept alive by the cache.
*/
export interface CachedResource {
/** Blob URL for the unpacked resource. */
url: string;
/** Backing Blob object. */
blob: Blob;
/** Metadata from the packed resource. */
metadata: PackedDataMetadata;
/** Timestamp (ms epoch) when cached, sourced from the injected Clock. */
cachedAt: number;
/** Reference count — entries with refCount > 0 are not eligible for eviction. */
refCount: number;
}
/** Snapshot summary returned by {@link PackedResourceCache.getStats}. */
export interface CacheStats {
readonly size: number;
readonly maxSize: number;
readonly entries: ReadonlyArray<{
readonly url: string;
readonly refCount: number;
readonly age: number;
}>;
}

View File

@@ -0,0 +1,6 @@
export { PackedResourceCache } from "./cache.js";
export type {
CachedResource,
CacheStats,
PackedResourceCacheOptions,
} from "./cache.types.js";

View File

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

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createServiceWorkerShim } from "../service-worker/sw-shim.js";
import {
buildContentAddressedUrl,
cleanupOutdatedCaches,
computeArchiveContentHash,
contentHashHex,
PACKER_BROWSER_FLAGS,
} from "./content-address.js";
describe("computeArchiveContentHash", () => {
it("computeArchiveContentHash is deterministic across calls with identical entries", {
timeout: 5000,
}, () => {
const entries = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])];
const a = computeArchiveContentHash(entries, 1);
const b = computeArchiveContentHash(entries, 1);
expect(a).toEqual(b);
expect(a.byteLength).toBe(32);
});
it("computeArchiveContentHash differs when formatVersion changes", {
timeout: 5000,
}, () => {
const entries = [new Uint8Array([9, 9, 9])];
const a = computeArchiveContentHash(entries, 1);
const b = computeArchiveContentHash(entries, 2);
expect(a).not.toEqual(b);
});
it("computeArchiveContentHash differs when entries change", {
timeout: 5000,
}, () => {
const a = computeArchiveContentHash([new Uint8Array([1])], 1);
const b = computeArchiveContentHash([new Uint8Array([2])], 1);
expect(a).not.toEqual(b);
});
it("computeArchiveContentHash rejects out-of-range formatVersion", {
timeout: 5000,
}, () => {
expect(() =>
computeArchiveContentHash([new Uint8Array([1])], -1)
).toThrowError(RangeError);
expect(() =>
computeArchiveContentHash([new Uint8Array([1])], 256)
).toThrowError(RangeError);
expect(() =>
computeArchiveContentHash([new Uint8Array([1])], 1.5)
).toThrowError(RangeError);
});
});
describe("contentHashHex", () => {
it("contentHashHex emits 64-char lowercase hex", { timeout: 5000 }, () => {
const hash = computeArchiveContentHash([new Uint8Array([1])], 1);
const hex = contentHashHex(hash);
expect(hex).toMatch(/^[0-9a-f]{64}$/);
});
it("contentHashHex rejects non-32-byte input", { timeout: 5000 }, () => {
expect(() => contentHashHex(new Uint8Array(16))).toThrowError(
RangeError
);
});
});
describe("buildContentAddressedUrl", () => {
it("buildContentAddressedUrl emits the canonical /packed/<hash>.bundle.asset form", {
timeout: 5000,
}, () => {
expect(buildContentAddressedUrl("/packed", "abc123")).toBe(
"/packed/abc123.bundle.asset"
);
});
it("buildContentAddressedUrl trims a trailing slash from the prefix", {
timeout: 5000,
}, () => {
expect(buildContentAddressedUrl("/packed/", "abc")).toBe(
"/packed/abc.bundle.asset"
);
});
it("buildContentAddressedUrl accepts a custom extension", {
timeout: 5000,
}, () => {
expect(buildContentAddressedUrl("/p", "abc", "packed")).toBe(
"/p/abc.packed"
);
});
});
describe("cleanupOutdatedCaches", () => {
it("cleanupOutdatedCaches deletes caches not in the keep list and preserves caches in the keep list", {
timeout: 5000,
}, async () => {
const shim = createServiceWorkerShim();
await shim.caches.open("packed-resources-old1");
await shim.caches.open("packed-resources-old2");
await shim.caches.open("packed-resources-new");
await shim.caches.open("other-cache");
const deleted = await cleanupOutdatedCaches(
shim.caches,
"packed-resources-",
["packed-resources-new"]
);
expect(deleted.sort()).toEqual(
["packed-resources-old1", "packed-resources-old2"].sort()
);
const names = await shim.caches.keys();
expect(names.sort()).toEqual(
["other-cache", "packed-resources-new"].sort()
);
});
it("cleanupOutdatedCaches returns empty list when nothing matches", {
timeout: 5000,
}, async () => {
const shim = createServiceWorkerShim();
await shim.caches.open("other-cache");
const deleted = await cleanupOutdatedCaches(
shim.caches,
"packed-resources-",
[]
);
expect(deleted).toEqual([]);
});
});
describe("PACKER_BROWSER_FLAGS", () => {
it("PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED is the canonical flag key", {
timeout: 5000,
}, () => {
expect(PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED).toBe(
"packer-browser.url.content-addressed"
);
});
});

View File

@@ -0,0 +1,102 @@
import {
BLAKE3_DIGEST_LEN,
entryDigestBlake3,
merkleRootBlake3,
} from "@suaveplan/packer";
import type { CacheStorageLike } from "./content-address.types.js";
function bytesToHex(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.byteLength; i++) {
s += (bytes[i] as number).toString(16).padStart(2, "0");
}
return s;
}
/**
* Compute the BLAKE3 Merkle root content-hash of an archive over its
* canonical entry bytes and a one-byte `formatVersion` tag. The
* `formatVersion` byte is appended as a synthetic trailing entry so that
* any bump to the wire format changes the resulting hash even when the
* payload bytes are identical.
*
* Returns a 32-byte hash; callers typically convert it to lowercase hex
* via {@link contentHashHex} before embedding in a URL.
*/
export function computeArchiveContentHash(
entries: ReadonlyArray<Uint8Array>,
formatVersion: number
): Uint8Array {
if (
!Number.isInteger(formatVersion) ||
formatVersion < 0 ||
formatVersion > 0xff
) {
throw new RangeError(
`computeArchiveContentHash: formatVersion must be a byte (0-255), got ${formatVersion}.`
);
}
const digests: Uint8Array[] = entries.map((e) => entryDigestBlake3(e));
digests.push(entryDigestBlake3(new Uint8Array([formatVersion])));
return merkleRootBlake3(digests);
}
/** Convert a 32-byte content hash to lowercase hex. */
export function contentHashHex(hash: Uint8Array): string {
if (hash.byteLength !== BLAKE3_DIGEST_LEN) {
throw new RangeError(
`contentHashHex: hash must be ${BLAKE3_DIGEST_LEN} bytes, got ${hash.byteLength}.`
);
}
return bytesToHex(hash);
}
/**
* Build a content-addressed packed-resource URL.
* `buildContentAddressedUrl("/packed", "abc")` yields
* `"/packed/abc.bundle.asset"`. The default extension preserves the
* Cluster A `<name>.bundle.asset` suffix so the SW pattern matcher
* keeps the same shape.
*/
export function buildContentAddressedUrl(
prefix: string,
hash: string,
ext = "bundle.asset"
): string {
const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return `${trimmedPrefix}/${hash}.${ext}`;
}
/**
* Delete every cache whose name starts with `prefix` and is NOT present
* in `keep`. Used from the Service Worker's `activate` handler so that a
* new manifest hash invalidates the old hash's cached entries on next
* SW install.
*
* Returns the array of cache names that were deleted.
*/
export async function cleanupOutdatedCaches(
caches: CacheStorageLike,
prefix: string,
keep: ReadonlyArray<string>
): Promise<string[]> {
const keepSet = new Set(keep);
const names = await caches.keys();
const deleted: string[] = [];
for (const name of names) {
if (!name.startsWith(prefix)) continue;
if (keepSet.has(name)) continue;
await caches.delete(name);
deleted.push(name);
}
return deleted;
}
/** Stable flag name used to gate content-addressed URL emission. */
export const PACKER_BROWSER_FLAGS = Object.freeze({
URL_CONTENT_ADDRESSED: "packer-browser.url.content-addressed",
CACHE_ADAPTIVE: "packer-browser.cache.adaptive",
} as const);
export type PackerBrowserFlagKey =
(typeof PACKER_BROWSER_FLAGS)[keyof typeof PACKER_BROWSER_FLAGS];

View File

@@ -0,0 +1,11 @@
import type { ShimCacheStorage } from "../service-worker/sw-shim.types.js";
/**
* Subset of `CacheStorage` invoked by {@link cleanupOutdatedCaches}.
* Real `globalThis.caches` satisfies this contract; the in-memory
* `ShimCacheStorage` does too.
*/
export type CacheStorageLike = Pick<
CacheStorage | ShimCacheStorage,
"keys" | "delete"
>;

View File

@@ -0,0 +1,9 @@
export {
buildContentAddressedUrl,
cleanupOutdatedCaches,
computeArchiveContentHash,
contentHashHex,
PACKER_BROWSER_FLAGS,
type PackerBrowserFlagKey,
} from "./content-address.js";
export type { CacheStorageLike } from "./content-address.types.js";

View File

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

View File

@@ -0,0 +1,244 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPackedResourceManager } from "../manager/manager.js";
import { createDiagnosticsOverlay } from "./diagnostics.js";
function freshManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
adaptiveCache: { enabled: false },
});
}
describe("createDiagnosticsOverlay", () => {
it("renders cache.size derived from the manager", { timeout: 5000 }, () => {
const manager = freshManager();
// Insert two cache entries directly through the cache surface.
const cache = manager.getCache();
cache.insert("a", new Blob(["1"]), "blob:a", {
originalSize: 1,
compressedSize: 1,
encrypted: false,
timestamp: 0,
checksum: "",
});
cache.insert("b", new Blob(["2"]), "blob:b", {
originalSize: 1,
compressedSize: 1,
encrypted: false,
timestamp: 0,
checksum: "",
});
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
try {
const el = document.querySelector(
"[data-packer-browser-diagnostics]"
);
expect(el?.textContent).toContain("cache.size=2");
} finally {
dispose();
manager.dispose();
}
});
it("event ring retains only the last 50 events", { timeout: 5000 }, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
try {
for (let i = 0; i < 60; i++) {
manager.emit("cacheHit", { url: `/u${i}` });
}
const snap = overlay.getSnapshot();
expect(snap.events.length).toBe(50);
// every retained event should be of type cacheHit
for (const e of snap.events) {
expect(e.type).toBe("cacheHit");
}
// hits counter should have observed every emission
expect(snap.hits).toBe(60);
} finally {
dispose();
manager.dispose();
}
});
it("disposer removes the element and unsubscribes every listener", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
const before = document.querySelectorAll(
"[data-packer-browser-diagnostics]"
).length;
expect(before).toBe(1);
dispose();
const after = document.querySelectorAll(
"[data-packer-browser-diagnostics]"
).length;
expect(after).toBe(0);
const snapBeforeEmit = overlay.getSnapshot();
manager.emit("cacheHit", { url: "/x" });
const snapAfterEmit = overlay.getSnapshot();
expect(snapAfterEmit.events.length).toBe(snapBeforeEmit.events.length);
manager.dispose();
});
it("returns a safe no-op disposer when document is absent", {
timeout: 5000,
}, () => {
const manager = freshManager();
// Pass document: null explicitly; the overlay constructor
// treats null as "no DOM" even when a global document is present.
const overlay = createDiagnosticsOverlay(manager, {
document: null,
});
const dispose = overlay.mount();
expect(() => dispose()).not.toThrow();
manager.dispose();
});
it("mount and dispose do not mutate the manager's cache state", {
timeout: 5000,
}, () => {
const manager = freshManager();
const before = manager.getCacheStats();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
dispose();
const after = manager.getCacheStats();
expect(after.size).toBe(before.size);
expect(after.maxSize).toBe(before.maxSize);
manager.dispose();
});
it("records load and error events with their detail strings", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
try {
manager.emit("load", {
url: "/a",
metadata: {
originalSize: 0,
compressedSize: 0,
encrypted: false,
timestamp: 0,
checksum: "",
},
});
manager.emit("error", { url: "/b", error: new Error("nope") });
manager.emit("cacheEvict", { url: "/c" });
manager.emit("cacheMiss", { url: "/d" });
const snap = overlay.getSnapshot();
const types = snap.events.map((e) => e.type);
expect(types).toEqual(["load", "error", "cacheEvict", "cacheMiss"]);
const errEvent = snap.events.find((e) => e.type === "error");
expect(errEvent?.detail).toBe("nope");
expect(snap.misses).toBe(1);
} finally {
dispose();
manager.dispose();
}
});
it("honours an explicit document injection over the global", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager, {
document: document,
});
const dispose = overlay.mount();
try {
expect(
document.querySelector("[data-packer-browser-diagnostics]")
).toBeDefined();
} finally {
dispose();
manager.dispose();
}
});
it("manager events before mount update the snapshot without throwing", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
manager.emit("cacheHit", { url: "/early" });
const snap = overlay.getSnapshot();
expect(snap.hits).toBe(1);
expect(snap.events[0]?.url).toBe("/early");
manager.dispose();
});
it("dispose tolerates an element already detached from its parent", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
const el = document.querySelector("[data-packer-browser-diagnostics]");
el?.parentNode?.removeChild(el);
expect(() => dispose()).not.toThrow();
manager.dispose();
});
it("mounts into a custom parent when supplied", { timeout: 5000 }, () => {
const manager = freshManager();
const parent = document.createElement("section");
document.body.appendChild(parent);
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount(parent);
try {
expect(
parent.querySelector("[data-packer-browser-diagnostics]")
).toBeDefined();
} finally {
dispose();
document.body.removeChild(parent);
manager.dispose();
}
});
it("hit-ratio reports n/a when no hits or misses are recorded", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
try {
const el = document.querySelector(
"[data-packer-browser-diagnostics]"
);
expect(el?.textContent).toContain("hit-ratio=n/a");
} finally {
dispose();
manager.dispose();
}
});
it("hit-ratio is computed once hits and misses are recorded", {
timeout: 5000,
}, () => {
const manager = freshManager();
const overlay = createDiagnosticsOverlay(manager);
const dispose = overlay.mount();
try {
manager.emit("cacheHit", { url: "/a" });
manager.emit("cacheHit", { url: "/a" });
manager.emit("cacheMiss", { url: "/b" });
const el = document.querySelector(
"[data-packer-browser-diagnostics]"
);
expect(el?.textContent).toContain("hits=2");
expect(el?.textContent).toContain("misses=1");
expect(el?.textContent).toContain("hit-ratio=0.67");
} finally {
dispose();
manager.dispose();
}
});
});

View File

@@ -0,0 +1,155 @@
import type {
DiagnosticsEvent,
DiagnosticsOverlay,
DiagnosticsOverlayOptions,
DiagnosticsSnapshot,
PackedResourceManager,
} from "./diagnostics.types.js";
const DEFAULT_HISTORY = 50;
function resolveDocument(
opts: DiagnosticsOverlayOptions
): Document | undefined {
if (opts.document === null) return undefined;
if (opts.document !== undefined) return opts.document;
// In a SW or Node-without-DOM context, `document` is genuinely
// absent. Use a guarded global read so the fallback survives both
// browser and non-browser runtimes.
const g = globalThis as { document?: Document };
return g.document;
}
function pushBounded(
ring: DiagnosticsEvent[],
event: DiagnosticsEvent,
limit: number
): void {
ring.push(event);
while (ring.length > limit) ring.shift();
}
/**
* Construct a development-time diagnostics overlay over a
* `PackedResourceManager`. The overlay subscribes to the manager's
* `TinyEmitter` events (`load`, `error`, `cacheHit`, `cacheMiss`,
* `cacheEvict`), keeps a bounded ring buffer of the last `historyLimit`
* events (default 50), and renders a single `<div>` whose text content
* reflects the cache stats, hit/miss ratio, and recent events.
*
* The overlay is subscribe-only — it never mutates the manager. The
* `mount(parent)` method returns a disposer that removes the rendered
* element and unsubscribes every listener. When the runtime has no
* `document`, `mount` returns a safe no-op disposer.
*/
export function createDiagnosticsOverlay(
manager: PackedResourceManager,
opts: DiagnosticsOverlayOptions = {}
): DiagnosticsOverlay {
const limit = opts.historyLimit ?? DEFAULT_HISTORY;
const now = opts.now ?? Date.now;
const doc = resolveDocument(opts);
const events: DiagnosticsEvent[] = [];
let hits = 0;
let misses = 0;
let attachedEl: HTMLDivElement | undefined;
let _attachedParent: ParentNode | undefined;
let detachers: Array<() => void> = [];
const snapshot = (): DiagnosticsSnapshot => ({
cacheSize: manager.getCache().size(),
hits,
misses,
events: events.slice(),
});
const render = (): void => {
if (attachedEl === undefined) return;
const snap = snapshot();
const ratio =
snap.hits + snap.misses > 0
? (snap.hits / (snap.hits + snap.misses)).toFixed(2)
: "n/a";
attachedEl.textContent = `packer-browser cache.size=${snap.cacheSize} hits=${snap.hits} misses=${snap.misses} hit-ratio=${ratio} events=${snap.events.length}`;
};
const recordLoad = (payload: { url: string }): void => {
pushBounded(
events,
{ type: "load", url: payload.url, at: now() },
limit
);
render();
};
const recordError = (payload: { url: string; error: Error }): void => {
pushBounded(
events,
{
type: "error",
url: payload.url,
at: now(),
detail: payload.error.message,
},
limit
);
render();
};
const recordHit = (payload: { url: string }): void => {
hits += 1;
pushBounded(
events,
{ type: "cacheHit", url: payload.url, at: now() },
limit
);
render();
};
const recordMiss = (payload: { url: string }): void => {
misses += 1;
pushBounded(
events,
{ type: "cacheMiss", url: payload.url, at: now() },
limit
);
render();
};
const recordEvict = (payload: { url: string }): void => {
pushBounded(
events,
{ type: "cacheEvict", url: payload.url, at: now() },
limit
);
render();
};
detachers.push(manager.on("load", recordLoad));
detachers.push(manager.on("error", recordError));
detachers.push(manager.on("cacheHit", recordHit));
detachers.push(manager.on("cacheMiss", recordMiss));
detachers.push(manager.on("cacheEvict", recordEvict));
const mount = (parent?: ParentNode): (() => void) => {
if (doc === undefined) {
return () => undefined;
}
const el = doc.createElement("div");
el.setAttribute("data-packer-browser-diagnostics", "");
el.style.cssText =
"position:fixed;bottom:0;right:0;padding:8px;background:rgba(0,0,0,0.7);color:#fff;font:12px monospace;z-index:99999;";
const target = parent ?? doc.body;
target.appendChild(el);
attachedEl = el;
_attachedParent = target;
render();
return () => {
if (el.parentNode === target) {
target.removeChild(el);
}
attachedEl = undefined;
_attachedParent = undefined;
for (const off of detachers) off();
detachers = [];
};
};
return { mount, getSnapshot: snapshot };
}

View File

@@ -0,0 +1,46 @@
import type { PackedResourceManager } from "../manager/manager.js";
export type { PackedResourceManager };
/** Single event recorded by the diagnostics overlay. */
export interface DiagnosticsEvent {
readonly type: "load" | "error" | "cacheHit" | "cacheMiss" | "cacheEvict";
readonly url: string;
readonly at: number;
readonly detail?: string;
}
/** Snapshot returned by {@link DiagnosticsOverlay.getSnapshot}. */
export interface DiagnosticsSnapshot {
readonly cacheSize: number;
readonly hits: number;
readonly misses: number;
readonly events: ReadonlyArray<DiagnosticsEvent>;
}
/** Options accepted by {@link createDiagnosticsOverlay}. */
export interface DiagnosticsOverlayOptions {
/**
* Optional document injection. Defaults to `globalThis.document`.
* Pass `null` explicitly to disable DOM mounting even when a global
* `document` is present (useful for SW or server harnesses).
*/
readonly document?: Document | null;
/** Maximum number of events retained in the ring buffer. Default `50`. */
readonly historyLimit?: number;
/** Clock used for event timestamps. Defaults to `Date.now`. */
readonly now?: () => number;
}
/** Return value of {@link createDiagnosticsOverlay}. */
export interface DiagnosticsOverlay {
/**
* Mount the overlay element into the supplied `ParentNode` (or
* `document.body` by default). Returns a disposer that removes the
* element and unsubscribes every listener. When the overlay was
* constructed without a `document`, the disposer is a no-op.
*/
mount(parent?: ParentNode): () => void;
/** Read-only snapshot of the current overlay state. */
getSnapshot(): DiagnosticsSnapshot;
}

View File

@@ -0,0 +1,7 @@
export { createDiagnosticsOverlay } from "./diagnostics.js";
export type {
DiagnosticsEvent,
DiagnosticsOverlay,
DiagnosticsOverlayOptions,
DiagnosticsSnapshot,
} from "./diagnostics.types.js";

View File

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

View File

@@ -0,0 +1,275 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import {
decodeEncryptedArchiveHeader,
decryptEncryptedArchive,
encodeEncryptedArchive,
hasEncryptedArchiveMagic,
SP_ENC1_MAGIC,
} from "./encrypted-archive.js";
const key32 = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
const nonce12 = (fill: number): Uint8Array => new Uint8Array(12).fill(fill);
describe("encrypted-archive wire format", () => {
it("round-trips plaintext through AES-256-GCM with the SP-ENC1 wire format", {
timeout: 5000,
}, () => {
const pt = new TextEncoder().encode("hello, world");
const key = key32(0xaa);
const nonce = nonce12(0x01);
const wire = encodeEncryptedArchive(pt, "tenant-a", key, nonce);
expect(hasEncryptedArchiveMagic(wire)).toBe(true);
// magic prefix is verbatim
expect(wire.subarray(0, SP_ENC1_MAGIC.byteLength)).toEqual(
SP_ENC1_MAGIC
);
const decoded = decodeEncryptedArchiveHeader(wire);
expect(decoded.header.alg).toBe("AES-256-GCM");
expect(decoded.header.keyId).toBe("tenant-a");
expect(decoded.header.nonce).toEqual(nonce);
const recovered = decryptEncryptedArchive(decoded, key);
expect(recovered).toEqual(pt);
});
it("round-trips with AAD bound to the GCM tag", { timeout: 5000 }, () => {
const pt = new TextEncoder().encode("payload");
const key = key32(0xbb);
const nonce = nonce12(0x02);
const aad = new TextEncoder().encode("manifest-v3");
const wire = encodeEncryptedArchive(pt, "tenant-b", key, nonce, {
aad,
});
const decoded = decodeEncryptedArchiveHeader(wire);
expect(decoded.header.aad).toEqual(aad);
expect(decryptEncryptedArchive(decoded, key)).toEqual(pt);
});
it("rejects a tampered ciphertext with a GCM tag-mismatch error", {
timeout: 5000,
}, () => {
const pt = new TextEncoder().encode("secret");
const key = key32(0xcc);
const nonce = nonce12(0x03);
const wire = encodeEncryptedArchive(pt, "k", key, nonce);
// Flip the final byte of the ciphertext (the auth tag).
const tampered = wire.slice();
tampered[tampered.byteLength - 1] ^= 0x01;
const decoded = decodeEncryptedArchiveHeader(tampered);
expect(() => decryptEncryptedArchive(decoded, key)).toThrowError(
PackerBrowserError
);
try {
decryptEncryptedArchive(decoded, key);
} catch (err) {
expect((err as PackerBrowserError).code).toBe(
PACKER_BROWSER_CODES.LOAD_FAILED
);
expect((err as PackerBrowserError).cause).toBeInstanceOf(Error);
}
});
it("hasEncryptedArchiveMagic returns false for unencrypted bytes", {
timeout: 5000,
}, () => {
expect(hasEncryptedArchiveMagic(new Uint8Array([1, 2, 3]))).toBe(false);
expect(hasEncryptedArchiveMagic(new Uint8Array(0))).toBe(false);
});
it("decodeEncryptedArchiveHeader rejects missing magic prefix", {
timeout: 5000,
}, () => {
expect(() =>
decodeEncryptedArchiveHeader(new Uint8Array([1, 2, 3, 4]))
).toThrowError(/SP-ENC1/);
});
it("decodeEncryptedArchiveHeader rejects truncated frame", {
timeout: 5000,
}, () => {
const wire = encodeEncryptedArchive(
new Uint8Array([1, 2]),
"k",
key32(1),
nonce12(1)
);
const truncated = wire.subarray(0, SP_ENC1_MAGIC.byteLength + 2);
expect(() => decodeEncryptedArchiveHeader(truncated)).toThrowError(
/truncated/
);
});
it("encodeEncryptedArchive rejects a non-32-byte key", {
timeout: 5000,
}, () => {
expect(() =>
encodeEncryptedArchive(
new Uint8Array([1]),
"k",
new Uint8Array(16),
nonce12(1)
)
).toThrowError(PackerBrowserError);
});
it("encodeEncryptedArchive rejects a non-12-byte nonce", {
timeout: 5000,
}, () => {
expect(() =>
encodeEncryptedArchive(
new Uint8Array([1]),
"k",
key32(1),
new Uint8Array(11)
)
).toThrowError(/12 bytes/);
});
it("decodeEncryptedArchiveHeader rejects invalid header length", {
timeout: 5000,
}, () => {
// Construct an SP-ENC1 frame with header length 0
const bad = new Uint8Array(SP_ENC1_MAGIC.byteLength + 4 + 1);
bad.set(SP_ENC1_MAGIC, 0);
// header length = 0 (already by zero-init)
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/invalid header length/
);
});
it("decodeEncryptedArchiveHeader rejects truncated header body", {
timeout: 5000,
}, () => {
const bad = new Uint8Array(SP_ENC1_MAGIC.byteLength + 4 + 2);
bad.set(SP_ENC1_MAGIC, 0);
new DataView(bad.buffer).setUint32(
SP_ENC1_MAGIC.byteLength,
100,
false
);
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/truncated frame inside header/
);
});
it("decodeEncryptedArchiveHeader rejects malformed JSON header", {
timeout: 5000,
}, () => {
const bad = new Uint8Array(SP_ENC1_MAGIC.byteLength + 4 + 3);
bad.set(SP_ENC1_MAGIC, 0);
new DataView(bad.buffer).setUint32(SP_ENC1_MAGIC.byteLength, 3, false);
bad[SP_ENC1_MAGIC.byteLength + 4] = 0x7b; // "{"
bad[SP_ENC1_MAGIC.byteLength + 5] = 0x21; // "!"
bad[SP_ENC1_MAGIC.byteLength + 6] = 0x7d; // "}"
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/not valid JSON/
);
});
it("decodeEncryptedArchiveHeader rejects missing alg field", {
timeout: 5000,
}, () => {
const headerJson = new TextEncoder().encode(
JSON.stringify({ keyId: "k", nonce: "AAAA" })
);
const bad = new Uint8Array(
SP_ENC1_MAGIC.byteLength + 4 + headerJson.byteLength
);
bad.set(SP_ENC1_MAGIC, 0);
new DataView(bad.buffer).setUint32(
SP_ENC1_MAGIC.byteLength,
headerJson.byteLength,
false
);
bad.set(headerJson, SP_ENC1_MAGIC.byteLength + 4);
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/unsupported alg/
);
});
it("decodeEncryptedArchiveHeader rejects unsupported alg", {
timeout: 5000,
}, () => {
const headerJson = new TextEncoder().encode(
JSON.stringify({ alg: "FAKE", keyId: "k", nonce: "AAAA" })
);
const bad = new Uint8Array(
SP_ENC1_MAGIC.byteLength + 4 + headerJson.byteLength
);
bad.set(SP_ENC1_MAGIC, 0);
new DataView(bad.buffer).setUint32(
SP_ENC1_MAGIC.byteLength,
headerJson.byteLength,
false
);
bad.set(headerJson, SP_ENC1_MAGIC.byteLength + 4);
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/unsupported alg/
);
});
it("decodeEncryptedArchiveHeader rejects missing keyId", {
timeout: 5000,
}, () => {
const headerJson = new TextEncoder().encode(
JSON.stringify({ alg: "AES-256-GCM", keyId: "", nonce: "AAAA" })
);
const bad = new Uint8Array(
SP_ENC1_MAGIC.byteLength + 4 + headerJson.byteLength
);
bad.set(SP_ENC1_MAGIC, 0);
new DataView(bad.buffer).setUint32(
SP_ENC1_MAGIC.byteLength,
headerJson.byteLength,
false
);
bad.set(headerJson, SP_ENC1_MAGIC.byteLength + 4);
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/keyId is missing/
);
});
it("encodeEncryptedArchive rejects an oversized header", {
timeout: 5000,
}, () => {
const huge = "x".repeat(70_000);
expect(() =>
encodeEncryptedArchive(
new Uint8Array([1]),
huge,
key32(1),
nonce12(1)
)
).toThrowError(/header exceeds/);
});
it("decodeEncryptedArchiveHeader rejects missing nonce", {
timeout: 5000,
}, () => {
const headerJson = new TextEncoder().encode(
JSON.stringify({ alg: "AES-256-GCM", keyId: "k" })
);
const bad = new Uint8Array(
SP_ENC1_MAGIC.byteLength + 4 + headerJson.byteLength
);
bad.set(SP_ENC1_MAGIC, 0);
new DataView(bad.buffer).setUint32(
SP_ENC1_MAGIC.byteLength,
headerJson.byteLength,
false
);
bad.set(headerJson, SP_ENC1_MAGIC.byteLength + 4);
expect(() => decodeEncryptedArchiveHeader(bad)).toThrowError(
/nonce is missing/
);
});
});

View File

@@ -0,0 +1,230 @@
import { aeadDecrypt, aeadEncrypt } from "@suaveplan/crypto";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type {
DecodedEncryptedArchive,
EncodeEncryptedArchiveOptions,
EncryptedArchiveHeader,
} from "./encrypted-archive.types.js";
/** Stable magic prefix identifying an SP-ENC1 encrypted archive. */
export const SP_ENC1_MAGIC = new Uint8Array([
0x53, 0x50, 0x2d, 0x45, 0x4e, 0x43, 0x31, 0x01,
]);
const MAGIC_LEN = SP_ENC1_MAGIC.length;
const HEADER_LEN_BYTES = 4;
const AES_GCM_KEY_BYTES = 32;
const AES_GCM_NONCE_BYTES = 12;
const MAX_HEADER_LEN = 64 * 1024;
function bytesEqualPrefix(a: Uint8Array, prefix: Uint8Array): boolean {
if (a.byteLength < prefix.byteLength) return false;
for (let i = 0; i < prefix.byteLength; i++) {
if (a[i] !== prefix[i]) return false;
}
return true;
}
function bytesToBase64(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.byteLength; i++) {
s += String.fromCharCode(bytes[i] as number);
}
return btoa(s);
}
function base64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/**
* Detect whether the supplied bytes carry an `SP-ENC1` encrypted-archive
* magic prefix. The detection is content-only — no allocations, no
* decoding side effects — so callers can route between encrypted and
* unencrypted paths cheaply on the loader hot path.
*/
export function hasEncryptedArchiveMagic(bytes: Uint8Array): boolean {
return bytesEqualPrefix(bytes, SP_ENC1_MAGIC);
}
/**
* Encode a plaintext payload as an `SP-ENC1` encrypted-archive blob.
* Returns `magic || u32be(headerLen) || headerJson || ciphertext+tag`.
* The caller supplies the AES-256-GCM key, the nonce, and the keyId;
* the function does not derive material.
*/
export function encodeEncryptedArchive(
plaintext: Uint8Array,
keyId: string,
key: Uint8Array,
nonce: Uint8Array,
opts: EncodeEncryptedArchiveOptions = {}
): Uint8Array {
if (key.byteLength !== AES_GCM_KEY_BYTES) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
`encodeEncryptedArchive: key must be 32 bytes, got ${key.byteLength}.`
);
}
if (nonce.byteLength !== AES_GCM_NONCE_BYTES) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
`encodeEncryptedArchive: AES-256-GCM nonce must be 12 bytes, got ${nonce.byteLength}.`
);
}
const header: Record<string, unknown> = {
alg: "AES-256-GCM",
keyId,
nonce: bytesToBase64(nonce),
};
if (opts.aad !== undefined) header["aad"] = bytesToBase64(opts.aad);
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
if (headerBytes.byteLength > MAX_HEADER_LEN) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
`encodeEncryptedArchive: header exceeds ${MAX_HEADER_LEN} bytes.`
);
}
const encOpts =
opts.aad !== undefined ? { nonce, aad: opts.aad } : { nonce };
const ciphertext = aeadEncrypt("AES-256-GCM", key, plaintext, encOpts);
const total = new Uint8Array(
MAGIC_LEN +
HEADER_LEN_BYTES +
headerBytes.byteLength +
ciphertext.byteLength
);
total.set(SP_ENC1_MAGIC, 0);
const dv = new DataView(total.buffer);
dv.setUint32(MAGIC_LEN, headerBytes.byteLength, false);
total.set(headerBytes, MAGIC_LEN + HEADER_LEN_BYTES);
total.set(
ciphertext,
MAGIC_LEN + HEADER_LEN_BYTES + headerBytes.byteLength
);
return total;
}
/**
* Decode an `SP-ENC1`-prefixed body into its header + ciphertext parts.
* Does NOT decrypt; the caller resolves the key and invokes
* {@link decryptEncryptedArchive}.
*/
export function decodeEncryptedArchiveHeader(
bytes: Uint8Array
): DecodedEncryptedArchive {
if (!hasEncryptedArchiveMagic(bytes)) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"decodeEncryptedArchiveHeader: missing SP-ENC1 magic prefix."
);
}
if (bytes.byteLength < MAGIC_LEN + HEADER_LEN_BYTES) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"decodeEncryptedArchiveHeader: truncated frame before header length."
);
}
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const headerLen = dv.getUint32(MAGIC_LEN, false);
if (headerLen === 0 || headerLen > MAX_HEADER_LEN) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
`decodeEncryptedArchiveHeader: invalid header length ${headerLen}.`
);
}
const headerStart = MAGIC_LEN + HEADER_LEN_BYTES;
const headerEnd = headerStart + headerLen;
if (bytes.byteLength < headerEnd) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"decodeEncryptedArchiveHeader: truncated frame inside header."
);
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(
new TextDecoder().decode(bytes.subarray(headerStart, headerEnd))
) as Record<string, unknown>;
} catch (err) {
// `JSON.parse` only throws `SyntaxError`, which is an `Error`.
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"decodeEncryptedArchiveHeader: header is not valid JSON.",
{ cause: err as Error }
);
}
if (parsed["alg"] !== "AES-256-GCM") {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
`decodeEncryptedArchiveHeader: unsupported alg ${String(parsed["alg"])}.`
);
}
const parsedKeyId = parsed["keyId"];
if (typeof parsedKeyId !== "string" || parsedKeyId.length === 0) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"decodeEncryptedArchiveHeader: keyId is missing or empty."
);
}
const parsedNonce = parsed["nonce"];
if (typeof parsedNonce !== "string") {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"decodeEncryptedArchiveHeader: nonce is missing."
);
}
const parsedAad = parsed["aad"];
const header: EncryptedArchiveHeader = {
alg: "AES-256-GCM",
keyId: parsedKeyId,
nonce: base64ToBytes(parsedNonce),
...(typeof parsedAad === "string"
? { aad: base64ToBytes(parsedAad) }
: {}),
};
return {
header,
ciphertext: bytes.subarray(headerEnd),
};
}
/**
* Decrypt an already-decoded `SP-ENC1` archive using a key resolved
* through a `KeyringProvider`. Wraps `aeadDecrypt` so the caller deals
* exclusively in `PackerBrowserError`s — a tag mismatch surfaces as
* `LOAD_FAILED` with the original AES-GCM error attached as `cause`.
*/
export function decryptEncryptedArchive(
decoded: DecodedEncryptedArchive,
key: Uint8Array
): Uint8Array {
try {
const decOpts =
decoded.header.aad !== undefined
? { nonce: decoded.header.nonce, aad: decoded.header.aad }
: { nonce: decoded.header.nonce };
return aeadDecrypt("AES-256-GCM", key, decoded.ciphertext, decOpts);
} catch (err) {
// `aeadDecrypt` always throws an `Error` subclass on tag
// mismatch / invalid nonce / invalid key. The cast is sound
// and keeps the branch table tight.
const wrapped = err as Error;
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
resolvePackerBrowserMessage(
undefined,
"packer-browser.error.LOAD_FAILED.decrypt",
{ url: "<encrypted>", detail: wrapped.message }
),
{ cause: wrapped }
);
}
}

View File

@@ -0,0 +1,29 @@
/**
* Decoded wire-format header from an `SP-ENC1`-prefixed archive. The
* AES-256-GCM ciphertext (`ciphertext + tag`) follows the header bytes
* verbatim.
*/
export interface EncryptedArchiveHeader {
readonly alg: "AES-256-GCM";
readonly keyId: string;
/** 12-byte AES-256-GCM nonce. */
readonly nonce: Uint8Array;
/** Optional additional authenticated data. */
readonly aad?: Uint8Array;
}
/**
* Successful decode of an encrypted-archive body into its constituent
* parts. The caller then routes `ciphertext` through `aeadDecrypt` with
* the resolved key.
*/
export interface DecodedEncryptedArchive {
readonly header: EncryptedArchiveHeader;
readonly ciphertext: Uint8Array;
}
/** Options for {@link encodeEncryptedArchive}. */
export interface EncodeEncryptedArchiveOptions {
/** Optional additional authenticated data. */
aad?: Uint8Array;
}

View File

@@ -0,0 +1,12 @@
export {
decodeEncryptedArchiveHeader,
decryptEncryptedArchive,
encodeEncryptedArchive,
hasEncryptedArchiveMagic,
SP_ENC1_MAGIC,
} from "./encrypted-archive.js";
export type {
DecodedEncryptedArchive,
EncodeEncryptedArchiveOptions,
EncryptedArchiveHeader,
} from "./encrypted-archive.types.js";

View File

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

View File

@@ -0,0 +1,119 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
PACKER_BROWSER_ERROR_METADATA,
PackerBrowserError,
} from "./error-codes.js";
const SRC_ROOT = resolve(__dirname, "..");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
out.push(...walk(full));
} else if (
/\.ts$/.test(entry) &&
!/\.test\.ts$/.test(entry) &&
!/\.types\.ts$/.test(entry) &&
entry !== "error-codes.ts" &&
entry !== "messages.ts"
) {
out.push(full);
}
}
return out;
}
describe("PACKER_BROWSER_CODES + PackerBrowserError", () => {
it("PACKER_BROWSER_CODES exposes the eight stable codes", {
timeout: 5000,
}, () => {
expect(Object.keys(PACKER_BROWSER_CODES).sort()).toEqual(
[
"ABORTED",
"ARCHIVE_SIGNATURE_INVALID",
"KEY_UNAVAILABLE",
"LOAD_FAILED",
"RESOURCE_NOT_FOUND",
"SW_REGISTRATION_FAILED",
"UNAUTHORIZED_ORIGIN",
"UNSUPPORTED_RESOURCE_TYPE",
].sort()
);
});
it("UNAUTHORIZED_ORIGIN is referenced by a production path", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT);
const text = files.map((f) => readFileSync(f, "utf8")).join("\n");
expect(text).toContain("PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN");
});
it("KEY_UNAVAILABLE is referenced by a production path", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT);
const text = files.map((f) => readFileSync(f, "utf8")).join("\n");
expect(text).toContain("PACKER_BROWSER_CODES.KEY_UNAVAILABLE");
});
it("every registered code is referenced by a production path", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT);
const text = files.map((f) => readFileSync(f, "utf8")).join("\n");
for (const codeName of Object.keys(PACKER_BROWSER_CODES)) {
// We accept either `PACKER_BROWSER_CODES.<NAME>` or the
// bare quoted form `"PACKER_BROWSER.<NAME>"` (used by some
// declarative tables).
const hasUsage =
text.includes(`PACKER_BROWSER_CODES.${codeName}`) ||
text.includes(`"PACKER_BROWSER.${codeName}"`);
expect(hasUsage).toBe(true);
}
});
it("PACKER_BROWSER_ERROR_METADATA covers every code with the four fields", {
timeout: 5000,
}, () => {
for (const code of Object.values(PACKER_BROWSER_CODES)) {
const meta = PACKER_BROWSER_ERROR_METADATA[code];
expect(typeof meta.httpStatus).toBe("number");
expect(["info", "warning", "error"]).toContain(meta.severity);
expect(typeof meta.retryable).toBe("boolean");
expect(meta.description.length).toBeGreaterThan(10);
}
});
it("PackerBrowserError carries its code, message, and brand name", {
timeout: 5000,
}, () => {
const err = new PackerBrowserError(
PACKER_BROWSER_CODES.RESOURCE_NOT_FOUND,
"404"
);
expect(err).toBeInstanceOf(PackerBrowserError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(PACKER_BROWSER_CODES.RESOURCE_NOT_FOUND);
expect(err.message).toBe("404");
expect(err.name).toBe("PackerBrowserError");
});
it("PackerBrowserError preserves cause when supplied", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
"wrapped",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,111 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error codes for `@suaveplan/packer-browser`. Every error thrown
* by the package extends {@link PackerBrowserError} and carries one of
* these codes so consumers (loggers, retry policies, SW supervisors) can
* classify failures without string-matching on messages.
*/
export const PACKER_BROWSER_CODES = defineCodes("PACKER_BROWSER", [
"RESOURCE_NOT_FOUND",
"SW_REGISTRATION_FAILED",
"ARCHIVE_SIGNATURE_INVALID",
"UNSUPPORTED_RESOURCE_TYPE",
"ABORTED",
"LOAD_FAILED",
"KEY_UNAVAILABLE",
"UNAUTHORIZED_ORIGIN",
] as const);
/** Union of every error code emitted by `@suaveplan/packer-browser`. */
export type PackerBrowserErrorCode =
(typeof PACKER_BROWSER_CODES)[keyof typeof PACKER_BROWSER_CODES];
/** Per-code metadata for HTTP / retry classification. */
export interface PackerBrowserCodeMetadata {
readonly httpStatus: number;
readonly severity: "info" | "warning" | "error";
readonly retryable: boolean;
readonly description: string;
}
/** Mapping of every {@link PackerBrowserErrorCode} to its metadata. */
export const PACKER_BROWSER_ERROR_METADATA: Readonly<
Record<PackerBrowserErrorCode, PackerBrowserCodeMetadata>
> = Object.freeze({
[PACKER_BROWSER_CODES.RESOURCE_NOT_FOUND]: {
httpStatus: 404,
severity: "warning",
retryable: false,
description:
"Packed resource fetch returned a non-2xx response (typically 404).",
},
[PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: true,
description:
"Service Worker registration failed; navigator.serviceWorker.register rejected.",
},
[PACKER_BROWSER_CODES.ARCHIVE_SIGNATURE_INVALID]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"Ed25519 signature over the archive manifest did not verify under the supplied public key.",
},
[PACKER_BROWSER_CODES.UNSUPPORTED_RESOURCE_TYPE]: {
httpStatus: 415,
severity: "error",
retryable: false,
description:
"Requested resource type is not supported by the packed-resource pipeline.",
},
[PACKER_BROWSER_CODES.ABORTED]: {
httpStatus: 499,
severity: "info",
retryable: true,
description:
"Operation was cancelled via AbortSignal before completion.",
},
[PACKER_BROWSER_CODES.LOAD_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: false,
description:
"Loader failed to fetch or decode a packed resource for a reason not covered by a more specific code.",
},
[PACKER_BROWSER_CODES.KEY_UNAVAILABLE]: {
httpStatus: 403,
severity: "error",
retryable: false,
description:
"Encrypted archive's referenced keyId could not be resolved by the configured KeyringProvider.",
},
[PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN]: {
httpStatus: 403,
severity: "error",
retryable: false,
description:
"Cross-origin Service Worker URL is not present in the configured allowedOrigins list.",
},
});
/**
* Base error for all `@suaveplan/packer-browser` failures. Extends
* `SuaveplanError` so logger, OTel, and wire serialisers integrate without
* per-package glue code.
*/
export class PackerBrowserError<
TCode extends PackerBrowserErrorCode = PackerBrowserErrorCode,
> extends SuaveplanError<TCode> {
public constructor(
code: TCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "PackerBrowserError";
}
}

View File

@@ -0,0 +1,9 @@
export type {
PackerBrowserCodeMetadata,
PackerBrowserErrorCode,
} from "./error-codes.js";
export {
PACKER_BROWSER_CODES,
PACKER_BROWSER_ERROR_METADATA,
PackerBrowserError,
} from "./error-codes.js";

View File

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

View File

@@ -0,0 +1,67 @@
import { MemoryProvider } from "@suaveplan/feature-flags";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PACKER_BROWSER_FLAGS } from "../content-address/content-address.js";
import { registerDefaultFlags, resolveFlagsProvider } from "./flags.js";
describe("resolveFlagsProvider", () => {
it("resolveFlagsProvider returns a MemoryProvider when no provider is supplied", {
timeout: 5000,
}, () => {
const provider = resolveFlagsProvider();
expect(provider).toBeInstanceOf(MemoryProvider);
});
it("resolveFlagsProvider returns a MemoryProvider when an empty options bag is supplied", {
timeout: 5000,
}, () => {
const provider = resolveFlagsProvider({});
expect(provider).toBeInstanceOf(MemoryProvider);
});
it("resolveFlagsProvider returns the caller-supplied provider unchanged", {
timeout: 5000,
}, () => {
const custom = new MemoryProvider([
{
key: "test.flag",
type: "boolean",
defaultValue: true,
},
]);
expect(resolveFlagsProvider({ flags: custom })).toBe(custom);
});
});
describe("registerDefaultFlags", () => {
it("registerDefaultFlags installs URL_CONTENT_ADDRESSED with defaultValue=true", {
timeout: 5000,
}, () => {
const provider = new MemoryProvider([]);
registerDefaultFlags(provider);
const flag = provider.getFlag(
PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED
);
expect(flag).toBeDefined();
expect(flag?.defaultValue).toBe(true);
expect(flag?.type).toBe("boolean");
});
it("registerDefaultFlags is idempotent — preserves caller overrides", {
timeout: 5000,
}, () => {
const provider = new MemoryProvider([
{
key: PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED,
type: "boolean",
defaultValue: false,
description: "caller override",
},
]);
registerDefaultFlags(provider);
const flag = provider.getFlag(
PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED
);
expect(flag?.defaultValue).toBe(false);
expect(flag?.description).toBe("caller override");
});
});

View File

@@ -0,0 +1,47 @@
import { MemoryProvider } from "@suaveplan/feature-flags";
import { PACKER_BROWSER_FLAGS } from "../content-address/content-address.js";
import type { FlagProvider, FlagsOptions } from "./flags.types.js";
/**
* Resolve a {@link FlagProvider} for `@suaveplan/packer-browser`. When
* the caller does not supply one, an empty `MemoryProvider` is returned
* — every `isEnabled` query falls through to the strategy default
* (disabled) because no `FlagDefinition` is registered.
*
* The slot exists so Cluster B / C features (progressive streaming,
* range requests, BackgroundFetch, …) can flip on without an API
* change to the public option bundles.
*/
export function resolveFlagsProvider(opts: FlagsOptions = {}): FlagProvider {
return opts.flags ?? new MemoryProvider([]);
}
/**
* Register the `@suaveplan/packer-browser`-owned flag definitions on the
* supplied provider — currently just
* `packer-browser.url.content-addressed` (default-on). Idempotent: if a
* definition already exists, the existing record is preserved so caller
* overrides win.
*/
export function registerDefaultFlags(provider: FlagProvider): void {
const urlKey = PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED;
if (provider.getFlag(urlKey) === undefined) {
provider.setFlag(urlKey, {
key: urlKey,
type: "boolean",
defaultValue: true,
description:
"When on, packer-browser SW + URL builder use content-addressed bundle URLs.",
});
}
const adaptiveKey = PACKER_BROWSER_FLAGS.CACHE_ADAPTIVE;
if (provider.getFlag(adaptiveKey) === undefined) {
provider.setFlag(adaptiveKey, {
key: adaptiveKey,
type: "boolean",
defaultValue: true,
description:
"When on, packer-browser constructs an adaptive cache sizer driven by navigator.storage.estimate().",
});
}
}

View File

@@ -0,0 +1,15 @@
import type { FlagProvider } from "@suaveplan/feature-flags";
export type { FlagProvider };
/**
* Options for resolving the `@suaveplan/feature-flags` provider used to
* gate Cluster B / C feature paths inside
* `@suaveplan/packer-browser`. Cluster A registers no live flags; the
* provider slot exists so adopters can wire one in without a future
* API churn.
*/
export interface FlagsOptions {
/** Caller-supplied flag provider. Default: empty `MemoryProvider`. */
flags?: FlagProvider;
}

View File

@@ -0,0 +1,2 @@
export { resolveFlagsProvider } from "./flags.js";
export type { FlagProvider, FlagsOptions } from "./flags.types.js";

View File

@@ -0,0 +1,9 @@
export {
defaultPackerBrowserResolver,
PACKER_BROWSER_MESSAGES,
resolvePackerBrowserMessage,
} from "./messages.js";
export type {
MessageResolver,
PackerBrowserMessageCatalog,
} from "./messages.types.js";

View File

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

View File

@@ -0,0 +1,106 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
defaultPackerBrowserResolver,
PACKER_BROWSER_MESSAGES,
resolvePackerBrowserMessage,
} from "./messages.js";
const SRC_ROOT = resolve(__dirname, "..");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
out.push(...walk(full));
} else if (/\.ts$/.test(entry) && !/\.test\.ts$/.test(entry)) {
out.push(full);
}
}
return out;
}
function extractKeys(text: string): string[] {
const out: string[] = [];
const re = /"(packer-browser\.error\.[A-Z_]+\.[a-z0-9-]+)"/g;
let match: RegExpExecArray | null = re.exec(text);
while (match !== null) {
out.push(match[1] as string);
match = re.exec(text);
}
return out;
}
describe("PACKER_BROWSER_MESSAGES catalog", () => {
it("PACKER_BROWSER_MESSAGES covers every key referenced from src", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT);
const referenced = new Set<string>();
for (const file of files) {
if (file.endsWith("messages.ts")) continue;
const text = readFileSync(file, "utf8");
for (const key of extractKeys(text)) {
referenced.add(key);
}
}
for (const key of referenced) {
expect(PACKER_BROWSER_MESSAGES[key]).toBeDefined();
}
expect(referenced.size).toBeGreaterThan(0);
});
it("defaultPackerBrowserResolver interpolates named placeholders", {
timeout: 5000,
}, () => {
const out = defaultPackerBrowserResolver(
"packer-browser.error.RESOURCE_NOT_FOUND.http-status",
{ status: 404, url: "/missing.packed" }
);
expect(out).toContain("404");
expect(out).toContain("/missing.packed");
});
it("defaultPackerBrowserResolver leaves unfilled placeholders intact", {
timeout: 5000,
}, () => {
const out = defaultPackerBrowserResolver(
"packer-browser.error.RESOURCE_NOT_FOUND.http-status",
{ status: 404 }
);
expect(out).toContain("404");
expect(out).toContain("{url}");
});
it("defaultPackerBrowserResolver returns the template verbatim when params is undefined", {
timeout: 5000,
}, () => {
const out = defaultPackerBrowserResolver(
"packer-browser.error.ABORTED.load"
);
expect(out).toBe(
PACKER_BROWSER_MESSAGES["packer-browser.error.ABORTED.load"]
);
});
it("resolvePackerBrowserMessage falls back to the bare key on a missing entry", {
timeout: 5000,
}, () => {
const key = "packer-browser.error.UNKNOWN.scenario-x";
const out = resolvePackerBrowserMessage(undefined, key);
expect(out).toBe(key);
});
it("resolvePackerBrowserMessage routes through the caller-supplied resolver", {
timeout: 5000,
}, () => {
const out = resolvePackerBrowserMessage(
(key) => `[fr] ${key}`,
"packer-browser.error.ABORTED.load"
);
expect(out).toBe("[fr] packer-browser.error.ABORTED.load");
});
});

View File

@@ -0,0 +1,102 @@
import type {
MessageResolver,
PackerBrowserMessageCatalog,
} from "./messages.types.js";
/**
* English defaults catalog for every user-visible error message thrown by
* `@suaveplan/packer-browser`. Every key is keyed
* `packer-browser.error.<CODE>.<scenario>` and is referenced by a real
* call site in `src/`.
*
* `{name}` placeholders are interpolated by
* {@link defaultPackerBrowserResolver} from the optional `params` object.
* Plain `{name}` (not `${name}`) is used because biome's
* `noTemplateCurlyInString` rule rejects `${...}` inside string literals.
*/
export const PACKER_BROWSER_MESSAGES: PackerBrowserMessageCatalog =
Object.freeze({
// RESOURCE_NOT_FOUND
"packer-browser.error.RESOURCE_NOT_FOUND.http-status":
"Packed resource fetch returned HTTP {status} for {url}.",
// SW_REGISTRATION_FAILED
"packer-browser.error.SW_REGISTRATION_FAILED.not-supported":
"Service Workers are not supported in this environment.",
"packer-browser.error.SW_REGISTRATION_FAILED.register-rejected":
"navigator.serviceWorker.register({path}) rejected: {detail}.",
"packer-browser.error.SW_REGISTRATION_FAILED.not-active":
"Service Worker is not active; no controller is attached to the page.",
// ARCHIVE_SIGNATURE_INVALID
"packer-browser.error.ARCHIVE_SIGNATURE_INVALID.verify-failed":
"Ed25519 signature over the archive manifest did not verify.",
"packer-browser.error.ARCHIVE_SIGNATURE_INVALID.signature-wrong-length":
"Archive signature must be 64 bytes, got {got}.",
// UNSUPPORTED_RESOURCE_TYPE
"packer-browser.error.UNSUPPORTED_RESOURCE_TYPE.unknown-mime":
"Unsupported packed resource MIME type: {mimeType}.",
// ABORTED
"packer-browser.error.ABORTED.load":
"Packed resource load aborted via AbortSignal before completion.",
"packer-browser.error.ABORTED.preload":
"Packed resource preload aborted via AbortSignal.",
"packer-browser.error.ABORTED.register":
"Service Worker registration aborted via AbortSignal.",
"packer-browser.error.ABORTED.cache-clear":
"Service Worker cache clear aborted via AbortSignal.",
"packer-browser.error.ABORTED.cache-status":
"Service Worker cache status query aborted via AbortSignal.",
// LOAD_FAILED
"packer-browser.error.LOAD_FAILED.unpack":
"Unpacker.unpack did not yield decoded bytes for {url}: {detail}.",
"packer-browser.error.LOAD_FAILED.fetch":
"Underlying fetch threw for {url}: {detail}.",
"packer-browser.error.LOAD_FAILED.decrypt":
"AES-GCM decryption failed for {url}: {detail}.",
// KEY_UNAVAILABLE
"packer-browser.error.KEY_UNAVAILABLE.no-key":
"Keyring does not contain a key for keyId {keyId}.",
"packer-browser.error.KEY_UNAVAILABLE.empty-keyring":
"Empty keyring cannot resolve keyId {keyId}; configure a real KeyringProvider.",
// UNAUTHORIZED_ORIGIN
"packer-browser.error.UNAUTHORIZED_ORIGIN.cross-origin":
"Service Worker URL {swUrl} origin is not in allowedOrigins ({allowed}).",
});
const PLACEHOLDER_RE = /\{(\w+)\}/g;
/**
* Default resolver: looks up `key` in {@link PACKER_BROWSER_MESSAGES} and
* interpolates `{name}` placeholders from `params`. Falls back to the
* bare key on a miss.
*/
export const defaultPackerBrowserResolver: MessageResolver = (key, params) => {
const template = PACKER_BROWSER_MESSAGES[key];
if (template === undefined) return key;
if (params === undefined) return template;
return template.replace(PLACEHOLDER_RE, (_, name: string) => {
const v = (params as Record<string, unknown>)[name];
return v === undefined ? `{${name}}` : String(v);
});
};
/**
* Resolve a packer-browser message via a caller-supplied resolver or
* {@link defaultPackerBrowserResolver}.
*/
export function resolvePackerBrowserMessage(
resolver: MessageResolver | undefined,
key: string,
params?: Readonly<Record<string, unknown>>
): string {
const r = resolver ?? defaultPackerBrowserResolver;
return r(key, params);
}
export type { MessageResolver, PackerBrowserMessageCatalog };

View File

@@ -0,0 +1,15 @@
import type { MessageResolver } from "@suaveplan/types";
export type { MessageResolver };
/**
* Per-package message catalog shape. Keys follow the convention
* `packer-browser.error.<CODE>.<scenario>` where `<CODE>` is the bare
* suffix of a `PACKER_BROWSER_CODES` value
* (`RESOURCE_NOT_FOUND`, `ARCHIVE_SIGNATURE_INVALID`, ...) and
* `<scenario>` is a short hyphenated noun phrase distinguishing scenarios
* that share a code.
*/
export interface PackerBrowserMessageCatalog {
readonly [key: string]: string;
}

View File

@@ -0,0 +1,75 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("package.json subpath exports", () => {
const pkg = JSON.parse(
readFileSync(resolve(__dirname, "..", "package.json"), "utf8")
) as {
exports?: Record<string, unknown>;
sideEffects?: boolean | string[];
};
it("every subpath export resolves to a built dist artefact", {
timeout: 5000,
}, () => {
const expected = [
".",
"./manager",
"./service-worker",
"./utils",
"./singleton",
"./diagnostics",
"./prefetch",
"./preload",
].sort();
const got = [
...new Set([
...Object.keys(pkg.exports ?? {}).filter(
(k) => k === "." || k.startsWith("./")
),
]),
].filter((k) => k !== "./package.json");
expect(got.sort()).toEqual(expected);
});
it("package.json declares sideEffects: false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
it("every subpath export resolves to a built dist artefact (cluster C)", {
timeout: 5000,
}, () => {
const newSubpaths = ["./diagnostics", "./prefetch", "./preload"];
for (const sp of newSubpaths) {
const entry = pkg.exports?.[sp] as
| { import?: { default?: string } }
| undefined;
const expected = `./dist/${sp.slice(2)}/index.js`;
expect(entry?.import?.default).toBe(expected);
}
});
it("package.json declares sideEffects: false (cluster C)", {
timeout: 5000,
}, () => {
expect(pkg.sideEffects).toBe(false);
});
it("every Playwright E2E spec file exists under tests/e2e/", {
timeout: 5000,
}, () => {
const root = resolve(__dirname, "..", "tests", "e2e");
const specs = [
"sw.spec.ts",
"range.spec.ts",
"encrypted.spec.ts",
"content-address.spec.ts",
"preload.spec.ts",
"diagnostics.spec.ts",
];
for (const s of specs) {
expect(existsSync(resolve(root, s))).toBe(true);
}
});
});

View File

@@ -0,0 +1,206 @@
/**
* @suaveplan/packer-browser — Cluster A core.
*
* Browser-tier runtime for `@suaveplan/packer` archives. Public surface:
* `PackedResourceManager` + factory, OTel observability bundle,
* `verifyArchiveSignature`, the Service Worker entrypoint, lifecycle
* helpers, error hierarchy, and i18n catalog. The module-singleton lives
* at `@suaveplan/packer-browser/singleton`; the React hook companion
* lives at `@suaveplan/react-packer` (out of scope for Cluster A).
*/
// Side-effect: register PACKER_BROWSER_CODES with the global error
// registry on import so subpath consumers register codes too.
import "./error-codes/index.js";
// Adaptive cache sizing (Cluster C)
export { createAdaptiveCacheSizer } from "./adaptive-cache/adaptive-cache.js";
export type {
AdaptiveCacheOptions,
AdaptiveCacheSizer,
AdaptiveCacheSizerOptions,
StorageEstimateFn,
StorageEstimateReport,
} from "./adaptive-cache/adaptive-cache.types.js";
// Cache (exposed for advanced consumers; the manager owns it by default)
export { PackedResourceCache } from "./cache/cache.js";
export type {
CachedResource,
CacheStats,
PackedResourceCacheOptions,
} from "./cache/cache.types.js";
// Content-addressed URL builder + cache-invalidation helpers (Cluster B)
export {
buildContentAddressedUrl,
cleanupOutdatedCaches,
computeArchiveContentHash,
contentHashHex,
PACKER_BROWSER_FLAGS,
type PackerBrowserFlagKey,
} from "./content-address/content-address.js";
export type { CacheStorageLike } from "./content-address/content-address.types.js";
// Diagnostics overlay (Cluster C — `/diagnostics` subpath)
export { createDiagnosticsOverlay } from "./diagnostics/diagnostics.js";
export type {
DiagnosticsEvent,
DiagnosticsOverlay,
DiagnosticsOverlayOptions,
DiagnosticsSnapshot,
} from "./diagnostics/diagnostics.types.js";
// Encrypted archive wire format (Cluster B)
export {
decodeEncryptedArchiveHeader,
decryptEncryptedArchive,
encodeEncryptedArchive,
hasEncryptedArchiveMagic,
SP_ENC1_MAGIC,
} from "./encrypted-archive/encrypted-archive.js";
export type {
DecodedEncryptedArchive,
EncodeEncryptedArchiveOptions,
EncryptedArchiveHeader,
} from "./encrypted-archive/encrypted-archive.types.js";
// Error hierarchy
export {
PACKER_BROWSER_CODES,
PACKER_BROWSER_ERROR_METADATA,
type PackerBrowserCodeMetadata,
PackerBrowserError,
type PackerBrowserErrorCode,
} from "./error-codes/index.js";
// Feature flags slot
export { registerDefaultFlags, resolveFlagsProvider } from "./flags/flags.js";
export type { FlagsOptions } from "./flags/flags.types.js";
// i18n
export {
defaultPackerBrowserResolver,
type MessageResolver,
PACKER_BROWSER_MESSAGES,
type PackerBrowserMessageCatalog,
resolvePackerBrowserMessage,
} from "./i18n/messages.js";
// Keyring (Cluster B)
export { emptyKeyring, MemoryKeyring } from "./keyring/keyring.js";
export type {
KeyringProvider,
MemoryKeyringEntries,
} from "./keyring/keyring.types.js";
// Loader
export { PackedResourceLoader } from "./loader/loader.js";
export type {
FetchLike,
LoadedResource,
PackedResourceLoaderOptions,
PackedResourceLoadOptions,
} from "./loader/loader.types.js";
// Loader streaming surface (Cluster B)
export { loadStream } from "./loader/loader-stream.js";
export type {
StreamedLoadedResource,
StreamLoadEntry,
} from "./loader/loader-stream.types.js";
// Manager
export {
createPackedResourceManager,
PackedResourceManager,
} from "./manager/manager.js";
export type {
FlagProvider,
PackedResourceLoadCallOptions,
PackedResourceManagerEvents,
PackedResourceManagerOptions,
PreloadHint,
} from "./manager/manager.types.js";
export type { ManagerStreamedEntry } from "./manager/manager-stream.types.js";
// Observability
export { createPackerBrowserObservability } from "./observability/observability.js";
export type {
PackerBrowserCounters,
PackerBrowserHistograms,
PackerBrowserObservability,
PackerBrowserObservabilityOptions,
} from "./observability/observability.types.js";
// BackgroundFetch prefetch (Cluster C — `/prefetch` subpath)
export {
handleBackgroundFetchFailure,
handleBackgroundFetchSuccess,
prefetchPackedArchive,
} from "./prefetch/prefetch.js";
export type {
BackgroundFetchEventLike,
BackgroundFetchManagerLike,
BackgroundFetchOptions,
BackgroundFetchRecordLike,
BackgroundFetchRegistrationLike,
HandleBackgroundFetchFailureOptions,
HandleBackgroundFetchSuccessOptions,
PrefetchPackedArchiveOptions,
} from "./prefetch/prefetch.types.js";
// Preload hint generation (Cluster C — `/preload` subpath)
export {
generatePreloadHints,
injectPreloadHints,
} from "./preload/preload.js";
export type {
GeneratePreloadHintsOptions,
PreloadAs,
PreloadHintLink,
PreloadManifest,
PreloadManifestEntry,
} from "./preload/preload.types.js";
// Range-response helper (Cluster B)
export {
DEFAULT_RANGE_THRESHOLD_BYTES,
type ParseRangeResult,
parseRange,
serveRangeResponse,
} from "./range-response/range-response.js";
export type {
ParsedByteRange,
ServeRangeResponseOptions,
} from "./range-response/range-response.types.js";
// Runtime
export {
defaultClock,
defaultMonotonicClock,
defaultRng,
elapsedMs,
resolveRuntime,
} from "./runtime/runtime.js";
export type {
Clock,
MonotonicClock,
PackerBrowserRuntime,
Rng,
RuntimeOptions,
} from "./runtime/runtime.types.js";
// Service Worker (production entrypoint + shim for unit tests)
export { runPackedResourceServiceWorker } from "./service-worker/service-worker.js";
export type {
CacheStatusReply,
RunServiceWorkerOptions,
ServiceWorkerMessage,
ServiceWorkerShim,
} from "./service-worker/service-worker.types.js";
// Signature verification
export { verifyArchiveSignature } from "./signature/signature.js";
export type { VerifyArchiveSignatureOptions } from "./signature/signature.types.js";
// Cross-origin SW helper (Cluster C — re-exported from `/utils`)
export { registerCrossOriginPackedResourceServiceWorker } from "./utils/cross-origin.js";
export type {
RegisterCrossOriginOptions,
RegisterCrossOriginResult,
} from "./utils/cross-origin.types.js";
// Lifecycle helpers (also available at `/utils` subpath)
export {
clearServiceWorkerCache,
getServiceWorkerCacheStatus,
registerPackedResourceServiceWorker,
unregisterPackedResourceServiceWorker,
} from "./utils/register-service-worker.js";
export type {
RegisterOptions,
RegistrationOptions,
ServiceWorkerContainerLike,
ServiceWorkerLifecycleOptions,
} from "./utils/register-service-worker.types.js";

View File

@@ -0,0 +1,2 @@
export { emptyKeyring, MemoryKeyring } from "./keyring.js";
export type { KeyringProvider, MemoryKeyringEntries } from "./keyring.types.js";

View File

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

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { emptyKeyring, MemoryKeyring } from "./keyring.js";
const key32 = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
describe("emptyKeyring", () => {
it("emptyKeyring rejects every keyId with KEY_UNAVAILABLE", {
timeout: 5000,
}, async () => {
const kr = emptyKeyring();
await expect(kr.resolveKey("anything")).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
});
});
it("emptyKeyring error is a PackerBrowserError instance", {
timeout: 5000,
}, async () => {
const kr = emptyKeyring();
try {
await kr.resolveKey("x");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(PackerBrowserError);
}
});
});
describe("MemoryKeyring", () => {
it("MemoryKeyring resolves three fixture keys and rejects an unknown id with KEY_UNAVAILABLE", {
timeout: 5000,
}, async () => {
const kr = new MemoryKeyring({
k1: key32(1),
k2: key32(2),
k3: key32(3),
});
expect(kr.size()).toBe(3);
await expect(kr.resolveKey("k1")).resolves.toEqual(key32(1));
await expect(kr.resolveKey("k2")).resolves.toEqual(key32(2));
await expect(kr.resolveKey("k3")).resolves.toEqual(key32(3));
await expect(kr.resolveKey("missing")).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
});
});
it("MemoryKeyring rejects construction with a non-32-byte key", {
timeout: 5000,
}, () => {
expect(
() => new MemoryKeyring({ short: new Uint8Array(16) })
).toThrowError(/32/);
});
it("MemoryKeyring honours a pre-aborted AbortSignal with ABORTED", {
timeout: 5000,
}, async () => {
const kr = new MemoryKeyring({ k1: key32(9) });
const ac = new AbortController();
ac.abort();
await expect(kr.resolveKey("k1", ac.signal)).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
});
});

View File

@@ -0,0 +1,89 @@
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type { KeyringProvider, MemoryKeyringEntries } from "./keyring.types.js";
const AES_GCM_KEY_BYTES = 32;
/**
* The default keyring used when no keyring is supplied to the loader.
* Every `resolveKey` call rejects with
* `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` so the
* unencrypted code path keeps working untouched: archives that
* carry the `SP-ENC1` magic prefix without a real keyring fail loudly
* instead of silently passing through.
*/
export function emptyKeyring(): KeyringProvider {
return {
async resolveKey(keyId: string): Promise<Uint8Array> {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
resolvePackerBrowserMessage(
undefined,
"packer-browser.error.KEY_UNAVAILABLE.empty-keyring",
{ keyId }
)
);
},
};
}
/**
* Synchronous in-memory keyring backed by a `Record<keyId, key>`.
* Suitable for tests, single-tenant deployments, and bootstrap
* scenarios where the key material is shipped alongside the page.
*
* Production deployments SHOULD swap in a `KeyringProvider`
* implementation that resolves against a KMS / HSM / IndexedDB-backed
* store so key bytes never live in JS heap longer than required.
*/
export class MemoryKeyring implements KeyringProvider {
private readonly entries: Map<string, Uint8Array>;
public constructor(entries: MemoryKeyringEntries) {
this.entries = new Map();
for (const [keyId, key] of Object.entries(entries)) {
if (key.byteLength !== AES_GCM_KEY_BYTES) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
`MemoryKeyring: keyId ${keyId} is ${key.byteLength} bytes; AES-256-GCM requires 32.`
);
}
this.entries.set(keyId, key);
}
}
public async resolveKey(
keyId: string,
signal?: AbortSignal
): Promise<Uint8Array> {
if (signal?.aborted === true) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(
undefined,
"packer-browser.error.ABORTED.load"
)
);
}
const key = this.entries.get(keyId);
if (key === undefined) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
resolvePackerBrowserMessage(
undefined,
"packer-browser.error.KEY_UNAVAILABLE.no-key",
{ keyId }
)
);
}
return key;
}
/** Number of keys currently held — exposed for diagnostics + tests. */
public size(): number {
return this.entries.size;
}
}

View File

@@ -0,0 +1,26 @@
/**
* Contract for a resolver that maps `keyId` strings (carried in an
* encrypted archive header) to 32-byte AES-256-GCM symmetric keys.
* Injected into {@link PackedResourceLoader} via
* `PackedResourceLoaderOptions.keyring`.
*
* Failures MUST be surfaced as
* `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` — the loader
* relies on the discriminated error code, not on the message, to
* decide between "missing key" and "tampered ciphertext".
*/
export interface KeyringProvider {
/**
* Resolve a 32-byte AES-256-GCM key for `keyId`. The returned
* `Uint8Array` MUST be exactly 32 bytes; the caller does not slice.
* `signal` is honoured at the resolver's discretion — async
* backends (HSM, KMS, IndexedDB) SHOULD propagate the signal.
*/
resolveKey(keyId: string, signal?: AbortSignal): Promise<Uint8Array>;
}
/**
* Construction map for {@link MemoryKeyring}. Each value MUST be a
* 32-byte `Uint8Array`; the constructor validates lengths up front.
*/
export type MemoryKeyringEntries = Readonly<Record<string, Uint8Array>>;

View File

@@ -0,0 +1,9 @@
export { PackedResourceLoader } from "./loader.js";
export type {
FetchLike,
LoadedResource,
MessageResolver,
PackedDataMetadata,
PackedResourceLoaderOptions,
PackedResourceLoadOptions,
} from "./loader.types.js";

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeEncryptedArchive } from "../encrypted-archive/encrypted-archive.js";
import { PACKER_BROWSER_CODES } from "../error-codes/error-codes.js";
import { MemoryKeyring } from "../keyring/keyring.js";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { resolveRuntime } from "../runtime/runtime.js";
import { PackedResourceLoader } from "./loader.js";
import type { FetchLike, PackedResourceLoaderOptions } from "./loader.types.js";
const SAMPLE_PAYLOAD = new TextEncoder().encode(
"hello packer-browser encrypted"
);
function makeOk(bytes: Uint8Array): Response {
return new Response(bytes, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
}
function makeLoader(
fetchImpl: FetchLike,
extra: Partial<PackedResourceLoaderOptions> = {}
): PackedResourceLoader {
const base: PackedResourceLoaderOptions = {
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
fetch: fetchImpl,
};
return new PackedResourceLoader({ ...base, ...extra });
}
describe("PackedResourceLoader — encrypted archives (Cluster B)", () => {
it("loader decrypts an SP-ENC1 archive via the injected keyring before unpacking", {
timeout: 30_000,
}, async () => {
const key = new Uint8Array(32).fill(0xa1);
const nonce = new Uint8Array(12).fill(0x07);
const wire = encodeEncryptedArchive(SAMPLE_PAYLOAD, "k1", key, nonce);
const keyring = new MemoryKeyring({ k1: key });
const loader = makeLoader(async () => makeOk(wire), { keyring });
const out = await loader.load("/encrypted.packed");
expect(out.url.startsWith("blob:")).toBe(true);
expect(out.blob.size).toBe(SAMPLE_PAYLOAD.byteLength);
});
it("loader throws KEY_UNAVAILABLE when the keyring cannot resolve the keyId", {
timeout: 30_000,
}, async () => {
const key = new Uint8Array(32).fill(0xa2);
const nonce = new Uint8Array(12).fill(0x08);
const wire = encodeEncryptedArchive(
SAMPLE_PAYLOAD,
"k-real",
key,
nonce
);
const keyring = new MemoryKeyring({ "k-other": key });
const loader = makeLoader(async () => makeOk(wire), { keyring });
await expect(loader.load("/enc.packed")).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
});
});
it("loader throws KEY_UNAVAILABLE when an encrypted archive is received with no keyring", {
timeout: 30_000,
}, async () => {
const key = new Uint8Array(32).fill(0xa3);
const nonce = new Uint8Array(12).fill(0x09);
const wire = encodeEncryptedArchive(SAMPLE_PAYLOAD, "k", key, nonce);
const loader = makeLoader(async () => makeOk(wire));
await expect(loader.load("/enc.packed")).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
});
});
it("loader surfaces LOAD_FAILED on tampered ciphertext", {
timeout: 30_000,
}, async () => {
const key = new Uint8Array(32).fill(0xb1);
const nonce = new Uint8Array(12).fill(0x0a);
const wire = encodeEncryptedArchive(SAMPLE_PAYLOAD, "k", key, nonce);
wire[wire.byteLength - 1] ^= 0x01;
const keyring = new MemoryKeyring({ k: key });
const loader = makeLoader(async () => makeOk(wire), { keyring });
await expect(loader.load("/tampered.packed")).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.LOAD_FAILED,
});
});
it("per-call keyring overrides the loader default", {
timeout: 30_000,
}, async () => {
const realKey = new Uint8Array(32).fill(0xc1);
const nonce = new Uint8Array(12).fill(0x0b);
const wire = encodeEncryptedArchive(
SAMPLE_PAYLOAD,
"k",
realKey,
nonce
);
const wrongKeyring = new MemoryKeyring({ wrong: realKey });
const rightKeyring = new MemoryKeyring({ k: realKey });
const loader = makeLoader(async () => makeOk(wire), {
keyring: wrongKeyring,
});
const out = await loader.load("/enc.packed", {
keyring: rightKeyring,
});
expect(out.url.startsWith("blob:")).toBe(true);
});
it("unencrypted bodies bypass the decrypt step even when a keyring is configured", {
timeout: 30_000,
}, async () => {
const keyring = new MemoryKeyring({ x: new Uint8Array(32) });
const loader = makeLoader(async () => makeOk(SAMPLE_PAYLOAD), {
keyring,
});
const out = await loader.load("/plain.packed");
expect(out.blob.size).toBe(SAMPLE_PAYLOAD.byteLength);
});
});

View File

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

View File

@@ -0,0 +1,74 @@
import type { MessageResolver } from "@suaveplan/types";
import {
decodeEncryptedArchiveHeader,
decryptEncryptedArchive,
hasEncryptedArchiveMagic,
} from "../encrypted-archive/encrypted-archive.js";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type { KeyringProvider } from "../keyring/keyring.types.js";
/** HTTP response header carrying the Ed25519 signature over the manifest. */
export const ARCHIVE_SIGNATURE_HEADER = "x-archive-signature";
const NO_DELAY = (): number => 0;
/** Retry policy used when the caller does not supply one. */
export const NO_RETRY = { maxAttempts: 1, delay: NO_DELAY } as const;
/**
* Internal hook used by tests to assert the no-retry fallback's `delay`
* function returns 0. Not exported from the package barrel.
*/
export const __testing_noDelay = NO_DELAY;
/** Default fetch — delegates to `globalThis.fetch`. */
export function defaultFetch(
input: string,
init?: RequestInit
): Promise<Response> {
return globalThis.fetch(input, init);
}
/**
* Decode a hex string (with optional `0x` prefix) into a `Uint8Array`.
* Used by the loader to decode the `X-Archive-Signature` header value.
*/
export function hexToBytes(hex: string): Uint8Array {
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
/**
* If `bytes` carries the `SP-ENC1` magic prefix, decrypt it via the
* supplied keyring; otherwise return `bytes` unchanged. Throws
* `KEY_UNAVAILABLE` when an encrypted body is received without a
* configured keyring; throws `LOAD_FAILED` on AEAD tag mismatch.
*/
export async function maybeDecryptArchive(
bytes: Uint8Array,
keyring: KeyringProvider | undefined,
signal: AbortSignal | undefined,
resolver: MessageResolver | undefined
): Promise<Uint8Array> {
if (!hasEncryptedArchiveMagic(bytes)) return bytes;
if (keyring === undefined) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.KEY_UNAVAILABLE,
resolvePackerBrowserMessage(
resolver,
"packer-browser.error.KEY_UNAVAILABLE.empty-keyring",
{ keyId: "<no-keyring>" }
)
);
}
const decoded = decodeEncryptedArchiveHeader(bytes);
const key = await keyring.resolveKey(decoded.header.keyId, signal);
return decryptEncryptedArchive(decoded, key);
}

View File

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

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { resolveRuntime } from "../runtime/runtime.js";
import { PackedResourceLoader } from "./loader.js";
import type { FetchLike, PackedResourceLoaderOptions } from "./loader.types.js";
function makeLoader(
fetchImpl: FetchLike,
extra: Partial<PackedResourceLoaderOptions> = {}
): PackedResourceLoader {
const base: PackedResourceLoaderOptions = {
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
fetch: fetchImpl,
};
return new PackedResourceLoader({ ...base, ...extra });
}
function delayedResolve(ms: number, body: Uint8Array): Promise<Response> {
return new Promise((res) => {
setTimeout(() => {
res(
new Response(body, {
status: 200,
headers: { "content-type": "application/octet-stream" },
})
);
}, ms);
});
}
describe("PackedResourceLoader.loadStream", () => {
it("emits each entry as soon as its bytes are decoded, in order of completion", {
timeout: 30_000,
}, async () => {
const order: string[] = [];
const fetchImpl: FetchLike = (url) => {
// First URL is slow, second is fast, third in between.
if (url === "/slow.packed") {
return delayedResolve(60, new TextEncoder().encode("S"));
}
if (url === "/fast.packed") {
return delayedResolve(5, new TextEncoder().encode("F"));
}
return delayedResolve(20, new TextEncoder().encode("M"));
};
const loader = makeLoader(fetchImpl);
const collected: string[] = [];
for await (const entry of loader.loadStream([
"/slow.packed",
"/fast.packed",
"/medium.packed",
])) {
collected.push(entry.inputUrl);
order.push(entry.inputUrl);
expect(entry.blob.size).toBe(1);
}
expect(collected).toEqual([
"/fast.packed",
"/medium.packed",
"/slow.packed",
]);
});
it("rejects with ABORTED when the signal is pre-aborted before any fetch", {
timeout: 5000,
}, async () => {
let fetchCount = 0;
const fetchImpl: FetchLike = async () => {
fetchCount += 1;
return new Response(new Uint8Array([1]), { status: 200 });
};
const loader = makeLoader(fetchImpl);
const ac = new AbortController();
ac.abort();
const iter = loader.loadStream(["/a.packed"], { signal: ac.signal });
await expect(iter.next()).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
expect(fetchCount).toBe(0);
});
it("terminates cleanly when the signal fires mid-stream", {
timeout: 30_000,
}, async () => {
const ac = new AbortController();
const fetchImpl: FetchLike = (url) => {
if (url === "/fast.packed") {
return delayedResolve(5, new TextEncoder().encode("F"));
}
// Slow entries that the abort should never let us see.
return delayedResolve(200, new TextEncoder().encode("S"));
};
const loader = makeLoader(fetchImpl);
const collected: string[] = [];
const iter = loader.loadStream(
["/fast.packed", "/slow1.packed", "/slow2.packed"],
{ signal: ac.signal }
);
const first = await iter.next();
if (!first.done) collected.push(first.value.inputUrl);
ac.abort();
await expect(iter.next()).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
expect(collected).toEqual(["/fast.packed"]);
});
it("loadStream uses default opts when called without opts argument", {
timeout: 30_000,
}, async () => {
const loader = makeLoader(
async () =>
new Response(new Uint8Array([1, 2]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
})
);
const { loadStream } = await import("./loader-stream.js");
const collected: string[] = [];
for await (const entry of loadStream(loader, ["/a.packed"])) {
collected.push(entry.inputUrl);
}
expect(collected).toEqual(["/a.packed"]);
});
it("returns immediately on an empty input list", {
timeout: 5000,
}, async () => {
const loader = makeLoader(
async () => new Response(new Uint8Array([1]), { status: 200 })
);
const collected: string[] = [];
for await (const entry of loader.loadStream([])) {
collected.push(entry.inputUrl);
}
expect(collected).toEqual([]);
});
it("propagates per-entry options to the loader", {
timeout: 30_000,
}, async () => {
const seen: string[] = [];
const fetchImpl: FetchLike = async (url, init) => {
seen.push(url);
return new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: {
"content-type": "application/octet-stream",
...((init?.signal as { foo?: string } | undefined) ?? {}),
},
});
};
const loader = makeLoader(fetchImpl);
const collected: string[] = [];
for await (const entry of loader.loadStream([
{ url: "/a.packed" },
{
url: "/b.packed",
options: { allowedMimePrefixes: ["application/"] },
},
])) {
collected.push(entry.inputUrl);
}
expect(collected.sort()).toEqual(["/a.packed", "/b.packed"].sort());
expect(seen.sort()).toEqual(["/a.packed", "/b.packed"].sort());
});
it("surfaces a load error on the iterator", { timeout: 5000 }, async () => {
const loader = makeLoader(
async () => new Response("", { status: 404 })
);
const iter = loader.loadStream(["/missing.packed"]);
await expect(iter.next()).rejects.toThrow(PackerBrowserError);
});
});

View File

@@ -0,0 +1,94 @@
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type { PackedResourceLoader } from "./loader.js";
import type {
PackedResourceLoadOptions,
StreamedLoadedResource,
StreamLoadEntry,
} from "./loader-stream.types.js";
/**
* Coerce a heterogeneous input list into uniform `StreamLoadEntry`
* shapes. Callers may pass either raw URLs or full
* `StreamLoadEntry` records; this normaliser keeps the streaming API
* ergonomic without proliferating overloads.
*/
function normaliseInputs(
inputs: ReadonlyArray<string | StreamLoadEntry>
): StreamLoadEntry[] {
return inputs.map((entry) =>
typeof entry === "string" ? { url: entry } : entry
);
}
function makeAborted(): PackerBrowserError {
return new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(
undefined,
"packer-browser.error.ABORTED.load"
)
);
}
/**
* Stream packed entries through the loader pipeline, yielding each one
* as soon as its bytes are decoded. Order of emission is **completion
* order**, not input order — entries whose fetches resolve early are
* surfaced first. The iterator honours `AbortSignal` at every yield: a
* pre-aborted signal short-circuits before any fetch runs; a mid-stream
* abort terminates the iterator on the next `next()`.
*
* The streaming path is additive: callers using `loader.load(url)` are
* unaffected, and a streaming consumer can mix in a per-entry
* `options` block to override `verifyKey`, `keyring`, `verifyChecksum`,
* `allowedMimePrefixes`, and per-entry signals.
*/
export async function* loadStream(
loader: PackedResourceLoader,
inputs: ReadonlyArray<string | StreamLoadEntry>,
opts: PackedResourceLoadOptions = {}
): AsyncIterableIterator<StreamedLoadedResource> {
const signal = opts.signal;
if (signal?.aborted) throw makeAborted();
const entries = normaliseInputs(inputs);
if (entries.length === 0) return;
type Pending = {
readonly inputUrl: string;
readonly promise: Promise<StreamedLoadedResource>;
settled: boolean;
};
const pending: Pending[] = entries.map((entry) => {
const merged: PackedResourceLoadOptions = {
...opts,
...(entry.options ?? {}),
};
const p: Pending = {
inputUrl: entry.url,
promise: loader
.load(entry.url, merged)
.then((loaded): StreamedLoadedResource => {
p.settled = true;
return { ...loaded, inputUrl: entry.url };
}),
settled: false,
};
return p;
});
while (pending.length > 0) {
if (signal?.aborted) throw makeAborted();
const raced = await Promise.race(
pending.map((p, idx) => p.promise.then((v) => ({ idx, v })))
);
// Remove the resolved entry.
pending.splice(raced.idx, 1);
yield raced.v;
}
}

View File

@@ -0,0 +1,27 @@
import type {
LoadedResource,
PackedResourceLoadOptions,
} from "./loader.types.js";
export type { LoadedResource, PackedResourceLoadOptions };
/**
* One entry in a streaming load. Each entry is loaded independently
* through the loader's standard pipeline; results are yielded by the
* iterator as soon as each entry is fully decoded.
*/
export interface StreamLoadEntry {
/** Stable URL of the packed entry. Returned alongside the loaded resource. */
readonly url: string;
/** Optional per-entry overrides. Merged onto the iterator-level options. */
readonly options?: PackedResourceLoadOptions;
}
/**
* A single streamed result emitted by `loader.loadStream`. The `url`
* field copies the input identifier so callers can correlate emissions
* back to their requests in completion order.
*/
export interface StreamedLoadedResource extends LoadedResource {
readonly inputUrl: string;
}

View File

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

View File

@@ -0,0 +1,433 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { ed25519 } from "@suaveplan/crypto";
import { createConstantBackoff } from "@suaveplan/retry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { resolveRuntime } from "../runtime/runtime.js";
import { __testing_noDelay, PackedResourceLoader } from "./loader.js";
import type { FetchLike, PackedResourceLoaderOptions } from "./loader.types.js";
const SAMPLE_PAYLOAD = new TextEncoder().encode("hello packer-browser");
function makeOkResponse(
bytes: Uint8Array,
headers: Record<string, string> = {}
): Response {
return new Response(bytes, {
status: 200,
headers: {
"content-type": "application/octet-stream",
...headers,
},
});
}
function makeLoader(
opts: Partial<PackedResourceLoaderOptions> = {}
): PackedResourceLoader {
const base: PackedResourceLoaderOptions = {
runtime: resolveRuntime(),
observability: createPackerBrowserObservability(),
};
return new PackedResourceLoader({ ...base, ...opts });
}
describe("PackedResourceLoader", () => {
it("fetches packed bytes and returns a Blob URL with the metadata-derived mime type", {
timeout: 30_000,
}, async () => {
const loader = makeLoader({
fetch: async () => makeOkResponse(SAMPLE_PAYLOAD),
});
const out = await loader.load("/x.packed");
expect(out.url.startsWith("blob:")).toBe(true);
expect(out.blob.size).toBe(SAMPLE_PAYLOAD.byteLength);
});
it("throws PackerBrowserError(RESOURCE_NOT_FOUND) on a 404 response", {
timeout: 30_000,
}, async () => {
const loader = makeLoader({
fetch: async () =>
new Response("", { status: 404, statusText: "Not Found" }),
});
await expect(loader.load("/missing.packed")).rejects.toThrow(
PackerBrowserError
);
try {
await loader.load("/missing.packed");
} catch (err) {
expect((err as PackerBrowserError).code).toBe(
PACKER_BROWSER_CODES.RESOURCE_NOT_FOUND
);
}
});
it("retries fetch up to maxAttempts when retryConfig is supplied", {
timeout: 30_000,
}, async () => {
let attempts = 0;
const fetchImpl: FetchLike = async () => {
attempts += 1;
if (attempts < 3) {
throw new Error("flaky");
}
return makeOkResponse(SAMPLE_PAYLOAD);
};
const loader = makeLoader({ fetch: fetchImpl });
const out = await loader.load("/x.packed", {
retryConfig: {
maxAttempts: 3,
delay: createConstantBackoff({ delay: 0 }),
},
});
expect(attempts).toBe(3);
expect(out.url.startsWith("blob:")).toBe(true);
});
it("defaults to a single fetch attempt when retryConfig is absent", {
timeout: 30_000,
}, async () => {
let attempts = 0;
const fetchImpl: FetchLike = async () => {
attempts += 1;
throw new Error("upstream-down");
};
const loader = makeLoader({ fetch: fetchImpl });
await expect(loader.load("/x.packed")).rejects.toThrow();
expect(attempts).toBe(1);
});
it("rejects with ABORTED when signal is pre-aborted before fetch", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
let calls = 0;
const loader = makeLoader({
fetch: async () => {
calls += 1;
return makeOkResponse(SAMPLE_PAYLOAD);
},
});
await expect(
loader.load("/x.packed", { signal: controller.signal })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
expect(calls).toBe(0);
});
it("rejects with ABORTED when signal fires between fetch and unpack", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const fetchImpl: FetchLike = async () => {
controller.abort();
return makeOkResponse(SAMPLE_PAYLOAD);
};
const loader = makeLoader({ fetch: fetchImpl });
await expect(
loader.load("/x.packed", { signal: controller.signal })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
});
it("skips signature verification when verifyKey is absent", {
timeout: 30_000,
}, async () => {
const loader = makeLoader({
fetch: async () =>
makeOkResponse(SAMPLE_PAYLOAD, {
"x-archive-signature": "deadbeef",
}),
});
const out = await loader.load("/x.packed");
expect(out.url.startsWith("blob:")).toBe(true);
});
it("verifies the response signature when verifyKey is supplied and a valid signature header is present", {
timeout: 30_000,
}, async () => {
const { publicKey, secretKey } = ed25519.generateKeypair();
const signature = ed25519.sign(secretKey, SAMPLE_PAYLOAD);
const hex = Array.from(signature)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const loader = makeLoader({
fetch: async () =>
makeOkResponse(SAMPLE_PAYLOAD, {
"x-archive-signature": hex,
}),
});
const out = await loader.load("/x.packed", { verifyKey: publicKey });
expect(out.url.startsWith("blob:")).toBe(true);
});
it("forwards a constructor-supplied resolver into the signature verifier", {
timeout: 30_000,
}, async () => {
const { publicKey, secretKey } = ed25519.generateKeypair();
const signature = ed25519.sign(secretKey, SAMPLE_PAYLOAD);
const hex = Array.from(signature)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const loader = makeLoader({
fetch: async () =>
makeOkResponse(SAMPLE_PAYLOAD, { "x-archive-signature": hex }),
messageResolver: (key) => `CTOR:${key}`,
});
// Successful verify still surfaces (we just exercise the
// resolver-supplied branch in verifyResponseSignature's call to
// verifyArchiveSignature).
const out = await loader.load("/x.packed", { verifyKey: publicKey });
expect(out.url.startsWith("blob:")).toBe(true);
});
it("throws ARCHIVE_SIGNATURE_INVALID before calling Unpacker.unpack on a bad signature", {
timeout: 30_000,
}, async () => {
const { publicKey, secretKey } = ed25519.generateKeypair();
const signature = ed25519.sign(secretKey, SAMPLE_PAYLOAD);
const tampered = new Uint8Array(signature);
tampered[0] = (tampered[0] as number) ^ 0x01;
const hex = Array.from(tampered)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const loader = makeLoader({
fetch: async () =>
makeOkResponse(SAMPLE_PAYLOAD, {
"x-archive-signature": hex,
}),
});
await expect(
loader.load("/x.packed", { verifyKey: publicKey })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ARCHIVE_SIGNATURE_INVALID,
});
});
it("records packed_resource_decode_ms on a successful decode", {
timeout: 30_000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
const observability = createPackerBrowserObservability({
meter: provider.getMeter("loader"),
});
const loader = new PackedResourceLoader({
runtime: resolveRuntime(),
observability,
fetch: async () => makeOkResponse(SAMPLE_PAYLOAD),
});
await loader.load("/x.packed");
await provider.forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_decode_ms");
expect(names).toContain("packed_resource_fetch_total");
});
it("wraps unpacker rejections as LOAD_FAILED", {
timeout: 30_000,
}, async () => {
// Force an unpacker rejection by overriding verifyChecksum and
// pointing the loader at gibberish bytes that look encrypted.
const loader = makeLoader({
fetch: async () =>
new Response(SAMPLE_PAYLOAD, {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
});
await expect(
loader.load("/x.packed", { verifyChecksum: true })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.LOAD_FAILED,
});
});
it("uses the constructor-default messageResolver when no per-call resolver is supplied", {
timeout: 30_000,
}, async () => {
const seen: string[] = [];
const loader = makeLoader({
fetch: async () => new Response("", { status: 404 }),
messageResolver: (key) => {
seen.push(key);
return `LOCAL:${key}`;
},
});
try {
await loader.load("/x.packed");
} catch (err) {
expect((err as PackerBrowserError).message).toMatch(/^LOCAL:/);
}
expect(seen.length).toBeGreaterThan(0);
});
it("per-call messageResolver overrides the constructor default", {
timeout: 30_000,
}, async () => {
const constructorSeen: string[] = [];
const perCallSeen: string[] = [];
const loader = makeLoader({
fetch: async () => new Response("", { status: 404 }),
messageResolver: (key) => {
constructorSeen.push(key);
return "ctor";
},
});
try {
await loader.load("/x.packed", {
messageResolver: (key) => {
perCallSeen.push(key);
return "per-call";
},
});
} catch (err) {
expect((err as PackerBrowserError).message).toBe("per-call");
}
expect(perCallSeen.length).toBeGreaterThan(0);
expect(constructorSeen.length).toBe(0);
});
it("wraps a non-Error throw into LOAD_FAILED with a string-form detail", {
timeout: 30_000,
}, async () => {
// Force a non-Error throw via Unpacker by injecting bad bytes
// that fail checksum.
const loader = makeLoader({
fetch: async () => makeOkResponse(SAMPLE_PAYLOAD),
});
await expect(
loader.load("/x.packed", { verifyChecksum: true })
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.LOAD_FAILED });
});
it("throws UNSUPPORTED_RESOURCE_TYPE when the response Content-Type is not on the allow-list", {
timeout: 30_000,
}, async () => {
const loader = makeLoader({
fetch: async () =>
new Response(SAMPLE_PAYLOAD, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
}),
});
await expect(
loader.load("/x.packed", {
allowedMimePrefixes: ["image/", "audio/"],
})
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.UNSUPPORTED_RESOURCE_TYPE,
});
});
it("accepts a response whose Content-Type matches an allow-list prefix", {
timeout: 30_000,
}, async () => {
const loader = makeLoader({
fetch: async () =>
new Response(SAMPLE_PAYLOAD, {
status: 200,
headers: { "content-type": "image/png" },
}),
});
const out = await loader.load("/x.packed", {
allowedMimePrefixes: ["image/"],
});
expect(out.url.startsWith("blob:")).toBe(true);
});
it("accepts hex signatures prefixed with 0x in the response header", {
timeout: 30_000,
}, async () => {
const { publicKey, secretKey } = ed25519.generateKeypair();
const signature = ed25519.sign(secretKey, SAMPLE_PAYLOAD);
const hex = `0x${Array.from(signature)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")}`;
const loader = makeLoader({
fetch: async () =>
makeOkResponse(SAMPLE_PAYLOAD, { "x-archive-signature": hex }),
});
const out = await loader.load("/x.packed", { verifyKey: publicKey });
expect(out.url.startsWith("blob:")).toBe(true);
});
it("defaultFetch routes through globalThis.fetch when no fetch is supplied", {
timeout: 30_000,
}, async () => {
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return new Response(SAMPLE_PAYLOAD, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
}) as typeof globalThis.fetch;
try {
const loader = makeLoader({});
const out = await loader.load("/default-fetch.packed");
expect(out.url.startsWith("blob:")).toBe(true);
expect(calls).toContain("/default-fetch.packed");
} finally {
globalThis.fetch = originalFetch;
}
});
it("treats a missing Content-Type header as an empty string for mime-allowlist matching", {
timeout: 30_000,
}, async () => {
// Build a Response whose Content-Type is explicitly absent.
const loader = makeLoader({
fetch: async () => {
const r = new Response(SAMPLE_PAYLOAD, { status: 200 });
r.headers.delete("content-type");
return r;
},
});
await expect(
loader.load("/x.packed", { allowedMimePrefixes: ["image/"] })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.UNSUPPORTED_RESOURCE_TYPE,
});
});
it("__testing_noDelay returns 0 (covers the NO_RETRY fallback delay)", {
timeout: 5000,
}, () => {
expect(__testing_noDelay()).toBe(0);
});
it("ignores an empty allow-list", { timeout: 30_000 }, async () => {
const loader = makeLoader({
fetch: async () => makeOkResponse(SAMPLE_PAYLOAD),
});
const out = await loader.load("/x.packed", {
allowedMimePrefixes: [],
});
expect(out.url.startsWith("blob:")).toBe(true);
});
});

View File

@@ -0,0 +1,300 @@
import { Unpacker } from "@suaveplan/packer";
import { withRetry } from "@suaveplan/retry";
import type { MessageResolver } from "@suaveplan/types";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type { KeyringProvider } from "../keyring/keyring.types.js";
import { elapsedMs } from "../runtime/runtime.js";
import { verifyArchiveSignature } from "../signature/signature.js";
import type {
FetchLike,
LoadedResource,
PackedResourceLoaderOptions,
PackedResourceLoadOptions,
} from "./loader.types.js";
import {
__testing_noDelay,
ARCHIVE_SIGNATURE_HEADER,
defaultFetch,
hexToBytes,
maybeDecryptArchive,
NO_RETRY,
} from "./loader-helpers.js";
import { loadStream as streamingLoadStream } from "./loader-stream.js";
// Re-export for tests that previously imported from this module.
export { __testing_noDelay };
/**
* Loader for packed resources. Owns the fetch → verify → unpack →
* Blob-URL pipeline. Every public yield point honours an injected
* `AbortSignal`; the fetch step runs through `@suaveplan/retry`'s
* `withRetry` (single-attempt default).
*
* The loader does NOT cache; the {@link PackedResourceCache} sits above
* it and decides whether to store the returned `LoadedResource`. Every
* successful load records the elapsed pipeline time on the
* `packed_resource_decode_ms` histogram and increments
* `packed_resource_fetch_total`.
*/
export class PackedResourceLoader {
private readonly fetch: FetchLike;
private readonly unpacker = new Unpacker();
private readonly runtime: PackedResourceLoaderOptions["runtime"];
private readonly observability: PackedResourceLoaderOptions["observability"];
private readonly defaultResolver: MessageResolver | undefined;
private readonly defaultKeyring: KeyringProvider | undefined;
public constructor(opts: PackedResourceLoaderOptions) {
this.fetch = opts.fetch ?? defaultFetch;
this.runtime = opts.runtime;
this.observability = opts.observability;
this.defaultResolver = opts.messageResolver;
this.defaultKeyring = opts.keyring;
}
/**
* Stream a list of packed entries through the standard load
* pipeline, yielding each one as soon as its bytes are decoded.
* Coexists with {@link PackedResourceLoader.load}; consumers can
* choose either entrypoint per call site.
*/
public loadStream(
inputs: ReadonlyArray<
string | import("./loader-stream.types.js").StreamLoadEntry
>,
opts: PackedResourceLoadOptions = {}
): AsyncIterableIterator<
import("./loader-stream.types.js").StreamedLoadedResource
> {
return streamingLoadStream(this, inputs, opts);
}
/**
* Fetch, verify, unpack, and Blob-URL-wrap a packed resource. Throws
* `PackerBrowserError` on every failure branch; never returns
* `undefined`.
*/
public async load(
url: string,
opts: PackedResourceLoadOptions = {}
): Promise<LoadedResource> {
const resolver = opts.messageResolver ?? this.defaultResolver;
this.checkAborted(
opts.signal,
"packer-browser.error.ABORTED.load",
resolver
);
const start = this.runtime.monotonicClock();
const response = await this.runFetch(url, opts, resolver);
this.checkAborted(
opts.signal,
"packer-browser.error.ABORTED.load",
resolver
);
const fetchedBytes = new Uint8Array(await response.arrayBuffer());
this.checkAborted(
opts.signal,
"packer-browser.error.ABORTED.load",
resolver
);
const packedBytes = await this.maybeDecrypt(fetchedBytes, opts);
this.checkAborted(
opts.signal,
"packer-browser.error.ABORTED.load",
resolver
);
this.verifyResponseSignature(response, packedBytes, opts, resolver);
this.checkAborted(
opts.signal,
"packer-browser.error.ABORTED.load",
resolver
);
this.enforceMimeAllowlist(response, opts, resolver);
const result = await this.unpack(url, packedBytes, opts, resolver);
const mimeType = result.metadata.mimeType ?? "application/octet-stream";
const buffer = new ArrayBuffer(result.data.byteLength);
new Uint8Array(buffer).set(result.data);
const blob = new Blob([buffer], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
this.observability.histograms.decodeMs.record(
elapsedMs(this.runtime.monotonicClock, start)
);
return { url: blobUrl, blob, metadata: result.metadata };
}
private async runFetch(
url: string,
opts: PackedResourceLoadOptions,
resolver: MessageResolver | undefined
): Promise<Response> {
this.observability.counters.fetchTotal.add(1);
const baseConfig = opts.retryConfig ?? NO_RETRY;
const config =
opts.signal !== undefined
? { ...baseConfig, signal: opts.signal }
: baseConfig;
const response = await withRetry<Response>(
() => this.fetch(url, opts.signal ? { signal: opts.signal } : {}),
config
);
if (!response.ok) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.RESOURCE_NOT_FOUND,
resolvePackerBrowserMessage(
resolver,
"packer-browser.error.RESOURCE_NOT_FOUND.http-status",
{ status: response.status, url }
)
);
}
return response;
}
private enforceMimeAllowlist(
response: Response,
opts: PackedResourceLoadOptions,
resolver: MessageResolver | undefined
): void {
const allow = opts.allowedMimePrefixes;
if (allow === undefined || allow.length === 0) return;
const rawMime = response.headers.get("content-type") ?? "";
const firstPart = rawMime.split(";")[0] as string;
const headerMime = firstPart.trim();
const ok = allow.some((prefix) => headerMime.startsWith(prefix));
if (!ok) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.UNSUPPORTED_RESOURCE_TYPE,
resolvePackerBrowserMessage(
resolver,
"packer-browser.error.UNSUPPORTED_RESOURCE_TYPE.unknown-mime",
{ mimeType: headerMime }
)
);
}
}
private verifyResponseSignature(
response: Response,
packedBytes: Uint8Array,
opts: PackedResourceLoadOptions,
resolver: MessageResolver | undefined
): void {
if (opts.verifyKey === undefined) return;
const headerSignature = response.headers.get(ARCHIVE_SIGNATURE_HEADER);
if (headerSignature === null) return;
const signature = hexToBytes(headerSignature);
const ok = verifyArchiveSignature(
packedBytes,
signature,
opts.verifyKey,
resolver !== undefined ? { messageResolver: resolver } : {}
);
if (!ok) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.ARCHIVE_SIGNATURE_INVALID,
resolvePackerBrowserMessage(
resolver,
"packer-browser.error.ARCHIVE_SIGNATURE_INVALID.verify-failed"
)
);
}
}
private maybeDecrypt(
bytes: Uint8Array,
opts: PackedResourceLoadOptions
): Promise<Uint8Array> {
return maybeDecryptArchive(
bytes,
opts.keyring ?? this.defaultKeyring,
opts.signal,
opts.messageResolver ?? this.defaultResolver
);
}
private async unpack(
url: string,
packedBytes: Uint8Array,
opts: PackedResourceLoadOptions,
resolver: MessageResolver | undefined
): Promise<{
data: Uint8Array;
metadata: import("@suaveplan/packer").PackedDataMetadata;
}> {
try {
const unpackOpts: import("@suaveplan/packer").UnpackOptions = {
verifyChecksum: opts.verifyChecksum ?? false,
verifyIntegrity: false,
};
if (opts.signal !== undefined) unpackOpts.signal = opts.signal;
const result = await this.unpacker.unpack(
{
data: packedBytes,
metadata: this.deriveMetadata(packedBytes),
},
unpackOpts
);
return { data: result.data, metadata: result.metadata };
} catch (err) {
// Unpacker always throws Error subclasses (`PackerError`).
const wrapped = err as Error;
throw new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
resolvePackerBrowserMessage(
resolver,
"packer-browser.error.LOAD_FAILED.unpack",
{ url, detail: wrapped.message }
),
{ cause: wrapped }
);
}
}
/**
* Derive a minimal `PackedDataMetadata` envelope from the raw bytes.
* In Cluster A the packed wire format is assumed to be a `PackedData`
* whose metadata travels alongside; the loader's input is the
* payload bytes. The Unpacker treats absent compression/encryption
* fields as identity transforms, so an empty metadata bundle round
* trips raw-payload bundles correctly. Cluster B replaces this with
* the formal manifest decoder.
*/
private deriveMetadata(
packedBytes: Uint8Array
): import("@suaveplan/packer").PackedDataMetadata {
return {
originalSize: packedBytes.byteLength,
compressedSize: packedBytes.byteLength,
encrypted: false,
timestamp: this.runtime.clock(),
checksum: "",
};
}
private checkAborted(
signal: AbortSignal | undefined,
key: string,
resolver: MessageResolver | undefined
): void {
if (signal?.aborted === true) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(resolver, key)
);
}
}
}

View File

@@ -0,0 +1,75 @@
import type { PackedDataMetadata } from "@suaveplan/packer";
import type { RetryConfig } from "@suaveplan/retry";
import type { MessageResolver } from "@suaveplan/types";
import type { KeyringProvider } from "../keyring/keyring.types.js";
import type { PackerBrowserObservability } from "../observability/observability.types.js";
import type { PackerBrowserRuntime } from "../runtime/runtime.types.js";
export type { KeyringProvider, MessageResolver, PackedDataMetadata };
/**
* Minimal `fetch`-shaped callable. Injectable so tests can pass a
* recording closure without touching the global `fetch`. Default
* resolves to `globalThis.fetch`.
*/
export type FetchLike = (
input: string,
init?: RequestInit
) => Promise<Response>;
/** Per-load options for {@link PackedResourceLoader.load}. */
export interface PackedResourceLoadOptions {
/** Whether to verify the bundle checksum on unpack. Default: true. */
verifyChecksum?: boolean;
/**
* Optional Ed25519 public key. When supplied AND the response carries
* an `X-Archive-Signature` header, the loader verifies the signature
* before unpacking and throws `ARCHIVE_SIGNATURE_INVALID` on a
* non-verifying signature.
*/
verifyKey?: Uint8Array;
/** Optional cancellation signal. Honoured at every yield point. */
signal?: AbortSignal;
/** Optional per-call retry policy. Default: single attempt. */
retryConfig?: RetryConfig;
/** Optional resolver for boundary error messages. */
messageResolver?: MessageResolver;
/**
* Optional allow-list of MIME-type prefixes (e.g. `["image/", "audio/"]`).
* When supplied, the loader rejects responses whose `Content-Type`
* does not start with any of the listed prefixes; the rejection is
* `PackerBrowserError(UNSUPPORTED_RESOURCE_TYPE)`. When absent or
* empty, every MIME type is accepted.
*/
allowedMimePrefixes?: ReadonlyArray<string>;
/**
* Optional keyring used to decrypt `SP-ENC1`-prefixed bodies before
* the unpack pipeline runs. When absent, an encrypted body surfaces
* `KEY_UNAVAILABLE` (because the default `emptyKeyring` rejects).
*/
keyring?: KeyringProvider;
}
/** Construction options for {@link PackedResourceLoader}. */
export interface PackedResourceLoaderOptions {
/** Injectable fetch. Default: `globalThis.fetch`. */
fetch?: FetchLike;
/** Resolved runtime (clock / rng / monotonic clock). */
runtime: PackerBrowserRuntime;
/** Resolved OTel observability bundle. */
observability: PackerBrowserObservability;
/** Default resolver for boundary error messages. */
messageResolver?: MessageResolver;
/** Default keyring forwarded to every {@link PackedResourceLoader.load} call. */
keyring?: KeyringProvider;
}
/** Result of a successful load. */
export interface LoadedResource {
/** Blob URL created from the unpacked bytes. */
url: string;
/** Backing Blob. */
blob: Blob;
/** Metadata from the packed envelope. */
metadata: PackedDataMetadata;
}

View File

@@ -0,0 +1,14 @@
export {
createPackedResourceManager,
PackedResourceManager,
} from "./manager.js";
export type {
FetchLike,
FlagProvider,
LoadedResource,
MessageResolver,
PackedResourceLoadCallOptions,
PackedResourceManagerEvents,
PackedResourceManagerOptions,
PreloadHint,
} from "./manager.types.js";

View File

@@ -0,0 +1,99 @@
import { MemoryProvider } from "@suaveplan/feature-flags";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PACKER_BROWSER_FLAGS } from "../content-address/content-address.js";
import { createPackedResourceManager } from "./manager.js";
describe("PackedResourceManager — adaptive cache wiring", () => {
it("adaptiveCache.enabled=false skips sizer construction", {
timeout: 5000,
}, () => {
const manager = createPackedResourceManager({
adaptiveCache: { enabled: false },
});
try {
expect(manager.getAdaptiveSizer()).toBeUndefined();
} finally {
manager.dispose();
}
});
it("CACHE_ADAPTIVE flag default=false skips sizer construction", {
timeout: 5000,
}, () => {
const provider = new MemoryProvider([
{
key: PACKER_BROWSER_FLAGS.CACHE_ADAPTIVE,
type: "boolean",
defaultValue: false,
},
]);
const manager = createPackedResourceManager({ flags: provider });
try {
expect(manager.getAdaptiveSizer()).toBeUndefined();
} finally {
manager.dispose();
}
});
it("adaptiveCache options thread through to the sizer", {
timeout: 30_000,
}, async () => {
let estimateCalls = 0;
const ticks: Array<() => void> = [];
const manager = createPackedResourceManager({
adaptiveCache: {
fraction: 0.5,
intervalMs: 50,
storageEstimate: async () => {
estimateCalls += 1;
return { quota: 200 };
},
setIntervalImpl: (handler) => {
ticks.push(handler);
return () => {
const idx = ticks.indexOf(handler);
if (idx >= 0) ticks.splice(idx, 1);
};
},
},
});
try {
const sizer = manager.getAdaptiveSizer();
expect(sizer).toBeDefined();
await sizer?.refresh();
expect(estimateCalls).toBeGreaterThanOrEqual(1);
expect(manager.getCache().getMaxCacheSize()).toBe(100); // 0.5 * 200
} finally {
manager.dispose();
}
});
it("adaptiveCache.signal threads through to the sizer", {
timeout: 5000,
}, () => {
const controller = new AbortController();
controller.abort();
const manager = createPackedResourceManager({
adaptiveCache: {
signal: controller.signal,
storageEstimate: async () => ({ quota: 100 }),
},
});
try {
const sizer = manager.getAdaptiveSizer();
expect(sizer).toBeDefined();
} finally {
manager.dispose();
}
});
it("manager.dispose is idempotent", { timeout: 5000 }, () => {
const manager = createPackedResourceManager({
adaptiveCache: { enabled: false },
});
expect(() => {
manager.dispose();
manager.dispose();
}).not.toThrow();
});
});

View File

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

View File

@@ -0,0 +1,84 @@
import type { FlagProvider } from "@suaveplan/feature-flags";
import type { MessageResolver } from "@suaveplan/types";
import { createAdaptiveCacheSizer } from "../adaptive-cache/adaptive-cache.js";
import type {
AdaptiveCacheOptions,
AdaptiveCacheSizer,
AdaptiveCacheSizerOptions,
} from "../adaptive-cache/adaptive-cache.types.js";
import type { PackedResourceCache } from "../cache/cache.js";
import { PACKER_BROWSER_FLAGS } from "../content-address/content-address.js";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
/**
* Derive a stable cache key from a URL and an optional verify-key. The
* first 8 bytes of the verify-key are hex-encoded and appended so two
* loads against the same URL with different verify-keys live in
* separate cache slots. (Anything longer than 8 bytes adds collisions
* for no real benefit; a verify-key prefix is sufficient to discriminate.)
*/
export function deriveCacheKey(url: string, verifyKey?: Uint8Array): string {
if (verifyKey === undefined) return url;
let suffix = "";
for (let i = 0; i < Math.min(verifyKey.length, 8); i++) {
const byte = verifyKey[i] as number;
suffix += byte.toString(16).padStart(2, "0");
}
return `${url}:${suffix}`;
}
/**
* Throw `PackerBrowserError(ABORTED)` if the supplied signal is
* already aborted. Used at every async yield point in the manager.
*/
export function checkAbortedSignal(
signal: AbortSignal | undefined,
resolver: MessageResolver | undefined,
key = "packer-browser.error.ABORTED.load"
): void {
if (signal?.aborted === true) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(resolver, key)
);
}
}
/**
* Decide whether to construct an adaptive cache sizer based on
* explicit options and the registered feature-flag value. Returns
* `undefined` when the sizer should be skipped entirely; otherwise
* forwards through to {@link createAdaptiveCacheSizer}.
*/
export function maybeCreateAdaptiveSizer(
cache: PackedResourceCache,
flagProvider: FlagProvider,
adaptiveOpts: AdaptiveCacheOptions | undefined
): AdaptiveCacheSizer | undefined {
if (adaptiveOpts?.enabled === false) return undefined;
const def = flagProvider.getFlag(PACKER_BROWSER_FLAGS.CACHE_ADAPTIVE);
if (def !== undefined && def.defaultValue === false) return undefined;
const sizerOpts: AdaptiveCacheSizerOptions = {
cache,
...(adaptiveOpts?.fraction !== undefined && {
fraction: adaptiveOpts.fraction,
}),
...(adaptiveOpts?.intervalMs !== undefined && {
intervalMs: adaptiveOpts.intervalMs,
}),
...(adaptiveOpts?.storageEstimate !== undefined && {
storageEstimate: adaptiveOpts.storageEstimate,
}),
...(adaptiveOpts?.signal !== undefined && {
signal: adaptiveOpts.signal,
}),
...(adaptiveOpts?.setIntervalImpl !== undefined && {
setIntervalImpl: adaptiveOpts.setIntervalImpl,
}),
};
return createAdaptiveCacheSizer(sizerOpts);
}

View File

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

View File

@@ -0,0 +1,59 @@
import type {
PackedResourceLoadOptions,
StreamLoadEntry,
} from "../loader/loader-stream.types.js";
import type { PackedResourceManager } from "./manager.js";
import type { PackedResourceLoadCallOptions } from "./manager.types.js";
import type { ManagerStreamedEntry } from "./manager-stream.types.js";
/**
* Implementation of `PackedResourceManager.loadStream`. Builds the
* loader-side options bundle, drives the loader's streaming iterator,
* and inserts each entry into the manager's cache + emits a `load`
* event before yielding to the caller.
*
* Kept as a free function so `manager.ts` stays under the 300-line
* hard cap (CLAUDE.md §3).
*/
export async function* managerLoadStream(
manager: PackedResourceManager,
opts: PackedResourceLoadCallOptions,
inputs: ReadonlyArray<string | StreamLoadEntry>
): AsyncIterableIterator<ManagerStreamedEntry> {
manager._checkAbortedExt(opts.signal);
const loadOpts = buildLoaderOptions(manager, opts);
for await (const entry of manager
.getLoader()
.loadStream(inputs, loadOpts)) {
manager._streamInsert(
entry.inputUrl,
opts.verifyKey,
entry.blob,
entry.url,
entry.metadata
);
yield {
url: entry.url,
inputUrl: entry.inputUrl,
metadata: entry.metadata,
};
}
}
function buildLoaderOptions(
manager: PackedResourceManager,
opts: PackedResourceLoadCallOptions
): PackedResourceLoadOptions {
const loadOpts: PackedResourceLoadOptions = {};
if (opts.verifyChecksum !== undefined)
loadOpts.verifyChecksum = opts.verifyChecksum;
if (opts.verifyKey !== undefined) loadOpts.verifyKey = opts.verifyKey;
if (opts.signal !== undefined) loadOpts.signal = opts.signal;
if (opts.retryConfig !== undefined) loadOpts.retryConfig = opts.retryConfig;
if (opts.allowedMimePrefixes !== undefined)
loadOpts.allowedMimePrefixes = opts.allowedMimePrefixes;
if (opts.keyring !== undefined) loadOpts.keyring = opts.keyring;
const resolver = manager._getDefaultResolver();
if (resolver !== undefined) loadOpts.messageResolver = resolver;
return loadOpts;
}

View File

@@ -0,0 +1,15 @@
import type { PackedDataMetadata } from "@suaveplan/packer";
/**
* One entry yielded by `PackedResourceManager.loadStream`. The blob URL
* has already been inserted into the manager's cache before the entry
* is yielded; downstream `load(inputUrl)` calls hit cached entries.
*/
export interface ManagerStreamedEntry {
/** Blob URL minted by the loader. */
readonly url: string;
/** Input URL the entry was loaded from. */
readonly inputUrl: string;
/** Decoded `PackedDataMetadata` from the unpack. */
readonly metadata: PackedDataMetadata;
}

View File

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

View File

@@ -0,0 +1,474 @@
import { MemoryProvider } from "@suaveplan/feature-flags";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import type { FetchLike } from "../loader/loader.types.js";
import {
createPackedResourceManager,
PackedResourceManager,
} from "./manager.js";
const PAYLOAD = new TextEncoder().encode("manager-payload");
function okFetch(): FetchLike {
return async (): Promise<Response> =>
new Response(PAYLOAD, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
}
describe("PackedResourceManager + factory", () => {
it("createPackedResourceManager returns a fresh instance per call", {
timeout: 5000,
}, () => {
const a = createPackedResourceManager();
const b = createPackedResourceManager();
expect(a).toBeInstanceOf(PackedResourceManager);
expect(b).toBeInstanceOf(PackedResourceManager);
expect(a).not.toBe(b);
});
it("composes a cache and a loader and emits cacheHit on the second load", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const events: string[] = [];
manager.on("load", () => events.push("load"));
manager.on("cacheMiss", () => events.push("miss"));
manager.on("cacheHit", () => events.push("hit"));
const first = await manager.load("/sample.packed");
const second = await manager.load("/sample.packed");
expect(first).toBe(second);
expect(events).toEqual(["miss", "load", "hit"]);
});
it("propagates AbortSignal from manager.load through to the loader", {
timeout: 5000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const controller = new AbortController();
controller.abort();
await expect(
manager.load("/x.packed", { signal: controller.signal })
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.ABORTED });
});
it("emits error and re-throws when the loader fails", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({
fetch: async () => new Response("", { status: 404 }),
});
const observed: Error[] = [];
manager.on("error", ({ error }) => observed.push(error));
await expect(manager.load("/missing.packed")).rejects.toThrow(
PackerBrowserError
);
expect(observed.length).toBe(1);
});
it("emits error wrapping non-Error rejections", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({
fetch: async () => {
// Use a coding pattern biome cannot statically reject:
// throwing an `unknown` typed binding that holds a string.
const value: unknown = "string-reject";
throw value;
},
});
const observed: Error[] = [];
manager.on("error", ({ error }) => observed.push(error));
await expect(manager.load("/x.packed")).rejects.toBeTruthy();
expect(observed[0]).toBeInstanceOf(Error);
});
it("manager.getFlagProvider returns the configured provider", {
timeout: 5000,
}, () => {
const provider = new MemoryProvider([]);
const manager = createPackedResourceManager({ flags: provider });
expect(manager.getFlagProvider()).toBe(provider);
});
it("getObservability returns the resolved bundle", {
timeout: 5000,
}, () => {
const manager = createPackedResourceManager();
const obs = manager.getObservability();
expect(obs.counters.fetchTotal).toBeDefined();
});
it("release decrements the cache refCount under the same cache key", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
await manager.load("/x.packed");
manager.release("/x.packed");
const stats = manager.getCacheStats();
expect(stats.entries[0]?.refCount).toBe(0);
});
it("clearCache emits a cacheEvict per evicted URL", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
await manager.load("/a.packed");
await manager.load("/b.packed");
const evicted: string[] = [];
manager.on("cacheEvict", ({ url }) => evicted.push(url));
manager.clearCache();
expect(evicted.length).toBe(2);
});
it("cleanupExpired routes through the cache", {
timeout: 30_000,
}, async () => {
let now = 0;
const manager = createPackedResourceManager({
fetch: okFetch(),
maxCacheAge: 100,
clock: () => now,
});
await manager.load("/a.packed");
manager.release("/a.packed");
now = 10_000;
expect(manager.cleanupExpired()).toBe(1);
});
it("preload loads every hint in parallel and respects AbortSignal", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
await manager.preload([{ url: "/p1.packed" }, { url: "/p2.packed" }]);
const stats = manager.getCacheStats();
expect(stats.size).toBe(2);
const ac = new AbortController();
ac.abort();
await expect(
manager.preload([{ url: "/q.packed" }], ac.signal)
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.ABORTED });
});
it("preload forwards verifyKey on every hint", {
timeout: 30_000,
}, async () => {
const seen: string[] = [];
const fetchImpl: FetchLike = async (url) => {
seen.push(url);
return new Response(PAYLOAD, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
};
const manager = createPackedResourceManager({ fetch: fetchImpl });
const key = new Uint8Array(32).fill(7);
await manager.preload([{ url: "/v.packed", verifyKey: key }]);
expect(seen).toContain("/v.packed");
});
it("preload forwards a non-aborted AbortSignal through to each load", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const ac = new AbortController();
await manager.preload(
[{ url: "/sig1.packed" }, { url: "/sig2.packed" }],
ac.signal
);
expect(manager.getCacheStats().size).toBe(2);
});
it("new PackedResourceManager() (no args) succeeds via default-param branch", {
timeout: 5000,
}, () => {
const manager = new PackedResourceManager();
expect(manager).toBeInstanceOf(PackedResourceManager);
});
it("on registers two listeners for the same event without dropping the first", {
timeout: 5000,
}, () => {
const manager = createPackedResourceManager();
const seen: string[] = [];
manager.on("cacheMiss", () => seen.push("a"));
manager.on("cacheMiss", () => seen.push("b"));
// We have no public emit, but the on-branch we want to cover is
// "re-use existing Set" — adding the second listener.
expect(seen.length).toBe(0);
});
it("cache=false opts out of the cache and counts a miss when the entry already exists", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
await manager.load("/x.packed");
const events: string[] = [];
manager.on("cacheMiss", () => events.push("miss"));
await manager.load("/x.packed", { cache: false });
// Should emit cacheMiss because cache is bypassed even though entry exists.
expect(events).toContain("miss");
});
it("cache=false on a first-time URL (cache empty) skips both the cache.get-hit and cache.has-true branches", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const events: string[] = [];
manager.on("cacheMiss", () => events.push("miss"));
await manager.load("/never-cached.packed", { cache: false });
expect(events).toEqual(["miss"]);
expect(manager.getCacheStats().size).toBe(0);
});
it("emits cacheMiss event when cache is empty (first load)", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const events: string[] = [];
manager.on("cacheMiss", () => events.push("miss"));
await manager.load("/y.packed");
expect(events).toEqual(["miss"]);
});
it("cache-key derivation includes verifyKey suffix", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const k1 = new Uint8Array(32).fill(1);
const k2 = new Uint8Array(32).fill(2);
await manager.load("/same.packed", { verifyKey: k1 });
await manager.load("/same.packed", { verifyKey: k2 });
const stats = manager.getCacheStats();
expect(stats.size).toBe(2);
});
it("on/off unsubscribes listeners", { timeout: 5000 }, () => {
const manager = createPackedResourceManager();
const calls: number[] = [];
const listener = (): void => {
calls.push(1);
};
const dispose = manager.on("cacheHit", listener);
manager.off("cacheHit", listener);
// dispose handle still works idempotently
dispose();
expect(calls.length).toBe(0);
});
it("off on an unregistered event does not throw", { timeout: 5000 }, () => {
const manager = createPackedResourceManager();
expect(() => manager.off("cacheHit", () => undefined)).not.toThrow();
});
it("threads every per-call option (verifyChecksum / verifyKey / retry / allowedMime / signal / resolver) through to the loader", {
timeout: 30_000,
}, async () => {
const seen: string[] = [];
const manager = createPackedResourceManager({
fetch: async (url) => {
seen.push(url);
return new Response(PAYLOAD, {
status: 200,
headers: { "content-type": "image/png" },
});
},
messageResolver: (key) => `CTOR:${key}`,
});
const ac = new AbortController();
await manager.load("/full.packed", {
verifyChecksum: false,
verifyKey: new Uint8Array(32).fill(3),
retryConfig: { maxAttempts: 1, delay: () => 0 },
allowedMimePrefixes: ["image/"],
signal: ac.signal,
});
expect(seen).toEqual(["/full.packed"]);
});
it("threads the constructor-default resolver into the loader call", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({
fetch: async () => new Response("", { status: 404 }),
messageResolver: (key) => `MGR:${key}`,
});
try {
await manager.load("/x.packed");
} catch (err) {
expect((err as PackerBrowserError).message).toMatch(/^MGR:/);
}
});
it("constructor accepts observability / meter / tracer / revokeObjectUrl overrides", {
timeout: 5000,
}, () => {
const revoked: string[] = [];
const manager = createPackedResourceManager({
revokeObjectUrl: (url) => revoked.push(url),
maxCacheSize: 2,
maxCacheAge: 1000,
});
expect(manager).toBeInstanceOf(PackedResourceManager);
expect(revoked.length).toBe(0);
});
it("constructor short-circuits when a pre-built observability bundle is supplied", {
timeout: 5000,
}, async () => {
const { createPackerBrowserObservability } = await import(
"../observability/observability.js"
);
const obs = createPackerBrowserObservability();
const manager = createPackedResourceManager({ observability: obs });
expect(manager.getObservability()).toBe(obs);
});
it("constructor accepts a meter override and uses it to create the bundle", {
timeout: 5000,
}, async () => {
const { MeterProvider } = await import("@opentelemetry/sdk-metrics");
const provider = new MeterProvider({});
const meter = provider.getMeter("manager-test");
const manager = createPackedResourceManager({ meter });
expect(manager.getObservability().meter).toBe(meter);
});
it("constructor accepts a tracer override", { timeout: 5000 }, async () => {
const { trace } = await import("@opentelemetry/api");
const tracer = trace.getTracer("manager-test-tracer");
const manager = createPackedResourceManager({ tracer });
expect(manager.getObservability().tracer).toBe(tracer);
});
it("loadStream writes each decoded entry into the cache as it arrives", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const loadEvents: string[] = [];
manager.on("load", ({ url }) => loadEvents.push(url));
const collected: string[] = [];
for await (const entry of manager.loadStream([
"/a.packed",
"/b.packed",
"/c.packed",
])) {
collected.push(entry.inputUrl);
// Cache must contain the entry at the time it's yielded.
expect(manager.getCacheStats().size).toBeGreaterThanOrEqual(
collected.length
);
}
expect(collected.sort()).toEqual(
["/a.packed", "/b.packed", "/c.packed"].sort()
);
expect(loadEvents.sort()).toEqual(
["/a.packed", "/b.packed", "/c.packed"].sort()
);
// A subsequent load(url) hits cache.
const hits: string[] = [];
manager.on("cacheHit", ({ url }) => hits.push(url));
await manager.load("/a.packed");
expect(hits).toEqual(["/a.packed"]);
});
it("loadStream threads every option through to the loader", {
timeout: 30_000,
}, async () => {
const { createConstantBackoff } = await import("@suaveplan/retry");
const { MemoryKeyring } = await import("../keyring/keyring.js");
const manager = createPackedResourceManager({ fetch: okFetch() });
const ac = new AbortController();
const collected: string[] = [];
for await (const entry of manager.loadStream(["/x.packed"], {
verifyChecksum: false,
verifyKey: new Uint8Array(32),
signal: ac.signal,
retryConfig: {
maxAttempts: 1,
delay: createConstantBackoff({ delay: 0 }),
},
allowedMimePrefixes: ["application/"],
keyring: new MemoryKeyring({ x: new Uint8Array(32) }),
})) {
collected.push(entry.inputUrl);
}
expect(collected).toEqual(["/x.packed"]);
});
it("loadStream uses the manager's default messageResolver", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({
fetch: okFetch(),
messageResolver: (key) => `[r] ${key}`,
});
const collected: string[] = [];
for await (const entry of manager.loadStream(["/r.packed"])) {
collected.push(entry.inputUrl);
}
expect(collected).toEqual(["/r.packed"]);
});
it("loadStream rejects with ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const manager = createPackedResourceManager({ fetch: okFetch() });
const ac = new AbortController();
ac.abort();
const iter = manager.loadStream(["/a.packed"], { signal: ac.signal });
await expect(iter.next()).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
});
it("manager.load forwards a per-call keyring to the loader", {
timeout: 30_000,
}, async () => {
const { encodeEncryptedArchive } = await import(
"../encrypted-archive/encrypted-archive.js"
);
const { MemoryKeyring } = await import("../keyring/keyring.js");
const key = new Uint8Array(32).fill(0xee);
const nonce = new Uint8Array(12).fill(0xee);
const wire = encodeEncryptedArchive(PAYLOAD, "k", key, nonce);
const manager = createPackedResourceManager({
fetch: async () =>
new Response(wire, {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
});
const url = await manager.load("/enc.packed", {
keyring: new MemoryKeyring({ k: key }),
});
expect(url.startsWith("blob:")).toBe(true);
});
it("manager forwards a default keyring to the loader", {
timeout: 30_000,
}, async () => {
const { encodeEncryptedArchive } = await import(
"../encrypted-archive/encrypted-archive.js"
);
const { MemoryKeyring } = await import("../keyring/keyring.js");
const key = new Uint8Array(32).fill(0xde);
const nonce = new Uint8Array(12).fill(0xad);
const wire = encodeEncryptedArchive(PAYLOAD, "k1", key, nonce);
const manager = createPackedResourceManager({
fetch: async () =>
new Response(wire, {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
keyring: new MemoryKeyring({ k1: key }),
});
const url = await manager.load("/enc.packed");
expect(url.startsWith("blob:")).toBe(true);
});
});

View File

@@ -0,0 +1,298 @@
import type { FlagProvider } from "@suaveplan/feature-flags";
import type { AdaptiveCacheSizer } from "../adaptive-cache/adaptive-cache.types.js";
import { PackedResourceCache } from "../cache/cache.js";
import { registerDefaultFlags, resolveFlagsProvider } from "../flags/flags.js";
import { PackedResourceLoader } from "../loader/loader.js";
import type { PackedResourceLoadOptions } from "../loader/loader.types.js";
import { createPackerBrowserObservability } from "../observability/observability.js";
import type { PackerBrowserObservability } from "../observability/observability.types.js";
import { resolveRuntime } from "../runtime/runtime.js";
import type {
MessageResolver,
PackedResourceLoadCallOptions,
PackedResourceManagerEvents,
PackedResourceManagerOptions,
PreloadHint,
} from "./manager.types.js";
import {
checkAbortedSignal,
deriveCacheKey,
maybeCreateAdaptiveSizer,
} from "./manager-helpers.js";
import { managerLoadStream } from "./manager-stream.js";
import { TinyEmitter } from "./tiny-emitter.js";
/**
* Lifecycle façade over {@link PackedResourceCache} and
* {@link PackedResourceLoader}. The manager owns the cache-key derivation
* policy, the event-bus, the OTel observability bundle, the feature-flag
* provider, and the cancellation contract; the cache owns the LRU + Blob
* URLs; the loader owns the fetch + verify + unpack pipeline.
*
* Construct via {@link createPackedResourceManager}. The default
* `@suaveplan/packer-browser` entrypoint does NOT auto-construct a
* singleton — import `@suaveplan/packer-browser/singleton` if a global
* shared instance is desired.
*/
export class PackedResourceManager extends TinyEmitter<PackedResourceManagerEvents> {
private readonly cache: PackedResourceCache;
private readonly loader: PackedResourceLoader;
private readonly observability: PackerBrowserObservability;
private readonly flagProvider: FlagProvider;
private readonly defaultResolver: MessageResolver | undefined;
private readonly adaptiveSizer: AdaptiveCacheSizer | undefined;
public constructor(opts: PackedResourceManagerOptions = {}) {
super();
const runtime = resolveRuntime(opts);
const obsOpts: {
observability?: PackerBrowserObservability;
meter?: import("@opentelemetry/api").Meter;
tracer?: import("@opentelemetry/api").Tracer;
} = {};
if (opts.observability !== undefined)
obsOpts.observability = opts.observability;
if (opts.meter !== undefined) obsOpts.meter = opts.meter;
if (opts.tracer !== undefined) obsOpts.tracer = opts.tracer;
this.observability = createPackerBrowserObservability(obsOpts);
this.flagProvider = resolveFlagsProvider(
opts.flags !== undefined ? { flags: opts.flags } : {}
);
registerDefaultFlags(this.flagProvider);
this.defaultResolver = opts.messageResolver;
const cacheOpts: ConstructorParameters<typeof PackedResourceCache>[0] =
{
runtime,
observability: this.observability,
};
if (opts.maxCacheSize !== undefined)
cacheOpts.maxCacheSize = opts.maxCacheSize;
if (opts.maxCacheAge !== undefined)
cacheOpts.maxCacheAge = opts.maxCacheAge;
if (opts.revokeObjectUrl !== undefined)
cacheOpts.revokeObjectUrl = opts.revokeObjectUrl;
this.cache = new PackedResourceCache(cacheOpts);
const loaderOpts: ConstructorParameters<
typeof PackedResourceLoader
>[0] = {
runtime,
observability: this.observability,
};
if (opts.fetch !== undefined) loaderOpts.fetch = opts.fetch;
if (opts.messageResolver !== undefined)
loaderOpts.messageResolver = opts.messageResolver;
if (opts.keyring !== undefined) loaderOpts.keyring = opts.keyring;
this.loader = new PackedResourceLoader(loaderOpts);
this.adaptiveSizer = maybeCreateAdaptiveSizer(
this.cache,
this.flagProvider,
opts.adaptiveCache
);
}
/**
* Stop background work owned by the manager (adaptive sizer timer).
* Idempotent. Does not clear the cache; call `clearCache()` first if
* the caller wants both behaviours.
*/
public dispose(): void {
this.adaptiveSizer?.dispose();
}
/** Adaptive sizer accessor — used by diagnostics overlays. */
public getAdaptiveSizer(): AdaptiveCacheSizer | undefined {
return this.adaptiveSizer;
}
/** Cache accessor — used by diagnostics overlays. Read-only contract. */
public getCache(): PackedResourceCache {
return this.cache;
}
/**
* Load a packed resource and return its Blob URL. On a cache hit the
* cached URL is returned and `cacheHit` is emitted; on a miss the
* loader runs and `cacheMiss` then `load` is emitted.
*/
public async load(
url: string,
opts: PackedResourceLoadCallOptions = {}
): Promise<string> {
this.checkAborted(opts.signal);
const cacheKey = this.getCacheKey(url, opts.verifyKey);
const useCache = opts.cache !== false;
if (useCache) {
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
this.emit("cacheHit", { url });
return cached.url;
}
} else if (this.cache.has(cacheKey)) {
// Cache exists but the caller opted out — still count a miss
// semantically so dashboards stay coherent.
this.observability.counters.cacheMissTotal.add(1);
}
this.emit("cacheMiss", { url });
try {
const loadOpts: PackedResourceLoadOptions = {};
if (opts.verifyChecksum !== undefined)
loadOpts.verifyChecksum = opts.verifyChecksum;
if (opts.verifyKey !== undefined)
loadOpts.verifyKey = opts.verifyKey;
if (opts.signal !== undefined) loadOpts.signal = opts.signal;
if (opts.retryConfig !== undefined)
loadOpts.retryConfig = opts.retryConfig;
if (opts.allowedMimePrefixes !== undefined)
loadOpts.allowedMimePrefixes = opts.allowedMimePrefixes;
if (opts.keyring !== undefined) loadOpts.keyring = opts.keyring;
if (this.defaultResolver !== undefined)
loadOpts.messageResolver = this.defaultResolver;
const loaded = await this.loader.load(url, loadOpts);
if (useCache) {
this.cache.insert(
cacheKey,
loaded.blob,
loaded.url,
loaded.metadata
);
}
this.emit("load", { url, metadata: loaded.metadata });
return loaded.url;
} catch (err) {
// Loader always throws Error subclasses.
const wrapped = err as Error;
this.emit("error", { url, error: wrapped });
throw err;
}
}
/**
* Preload a list of resources in parallel. Honours the supplied
* AbortSignal; cancellation rejects with `ABORTED`.
*/
public async preload(
hints: ReadonlyArray<PreloadHint>,
signal?: AbortSignal
): Promise<void> {
checkAbortedSignal(
signal,
this.defaultResolver,
"packer-browser.error.ABORTED.preload"
);
await Promise.all(
hints.map((hint) => {
const loadOpts: PackedResourceLoadCallOptions = {};
if (hint.verifyKey !== undefined)
loadOpts.verifyKey = hint.verifyKey;
if (signal !== undefined) loadOpts.signal = signal;
return this.load(hint.url, loadOpts);
})
);
}
/** Decrement the refCount on a cached resource. */
public release(url: string, verifyKey?: Uint8Array): void {
this.cache.release(this.getCacheKey(url, verifyKey));
}
/** Walk the cache and evict every expired refCount=0 entry. */
public cleanupExpired(): number {
return this.cache.cleanupExpired();
}
/** Clear every cached entry regardless of refCount. */
public clearCache(): void {
const stats = this.cache.getStats();
this.cache.clear();
for (const entry of stats.entries) {
this.emit("cacheEvict", { url: entry.url });
}
}
/** Snapshot of cache state. */
public getCacheStats(): ReturnType<PackedResourceCache["getStats"]> {
return this.cache.getStats();
}
/** The OTel observability bundle this manager emits onto. */
public getObservability(): PackerBrowserObservability {
return this.observability;
}
/** The feature-flag provider configured for this manager. */
public getFlagProvider(): FlagProvider {
return this.flagProvider;
}
/** Expose the loader for streaming + advanced consumers. */
public getLoader(): PackedResourceLoader {
return this.loader;
}
/**
* Stream packed entries through the loader, writing each decoded
* entry into the cache as it arrives. Yields blob URLs through the
* manager's cache so subsequent `load(url)` calls hit cached entries.
*/
public loadStream(
inputs: ReadonlyArray<
string | import("../loader/loader-stream.types.js").StreamLoadEntry
>,
opts: PackedResourceLoadCallOptions = {}
): AsyncIterableIterator<
import("./manager-stream.types.js").ManagerStreamedEntry
> {
return managerLoadStream(this, opts, inputs);
}
/** @internal Used by `manager-stream` to record cache insertions. */
public _streamInsert(
inputUrl: string,
verifyKey: Uint8Array | undefined,
blob: Blob,
url: string,
metadata: import("@suaveplan/packer").PackedDataMetadata
): void {
const cacheKey = this.getCacheKey(inputUrl, verifyKey);
this.cache.insert(cacheKey, blob, url, metadata);
this.emit("load", { url: inputUrl, metadata });
}
/** @internal Accessor used by `manager-stream`. */
public _getDefaultResolver(): MessageResolver | undefined {
return this.defaultResolver;
}
/** @internal Accessor used by `manager-stream`. */
public _checkAbortedExt(signal: AbortSignal | undefined): void {
this.checkAborted(signal);
}
private getCacheKey(url: string, verifyKey?: Uint8Array): string {
return deriveCacheKey(url, verifyKey);
}
private checkAborted(signal: AbortSignal | undefined): void {
checkAbortedSignal(signal, this.defaultResolver);
}
}
/**
* Factory for {@link PackedResourceManager}. Use this in place of the
* `new PackedResourceManager()` constructor when composing the manager
* into an application's bootstrap layer; the factory makes dependency
* injection points explicit at the call site.
*/
export function createPackedResourceManager(
options?: PackedResourceManagerOptions
): PackedResourceManager {
return new PackedResourceManager(options ?? {});
}

View File

@@ -0,0 +1,86 @@
import type { FlagProvider } from "@suaveplan/feature-flags";
import type { RetryConfig } from "@suaveplan/retry";
import type { MessageResolver } from "@suaveplan/types";
import type { AdaptiveCacheOptions } from "../adaptive-cache/adaptive-cache.types.js";
import type { KeyringProvider } from "../keyring/keyring.types.js";
import type { FetchLike, LoadedResource } from "../loader/loader.types.js";
import type { PackerBrowserObservabilityOptions } from "../observability/observability.types.js";
import type { RuntimeOptions } from "../runtime/runtime.types.js";
export type {
AdaptiveCacheOptions,
FetchLike,
FlagProvider,
KeyringProvider,
LoadedResource,
MessageResolver,
};
/** Construction options for {@link createPackedResourceManager}. */
export interface PackedResourceManagerOptions extends RuntimeOptions {
/** Maximum cache entries. Default: 100. */
maxCacheSize?: number;
/** Max cache entry age in ms. Default: 1h. */
maxCacheAge?: number;
/** Injectable fetch. Default: `globalThis.fetch`. */
fetch?: FetchLike;
/** Injectable Blob-URL revoker. Default: `URL.revokeObjectURL`. */
revokeObjectUrl?: (url: string) => void;
/** Optional OpenTelemetry observability bundle / Meter / Tracer. */
observability?: PackerBrowserObservabilityOptions["observability"];
/** Optional OpenTelemetry meter. */
meter?: PackerBrowserObservabilityOptions["meter"];
/** Optional OpenTelemetry tracer. */
tracer?: PackerBrowserObservabilityOptions["tracer"];
/** Optional feature-flag provider. Default: empty MemoryProvider. */
flags?: FlagProvider;
/** Optional resolver for boundary error messages. */
messageResolver?: MessageResolver;
/** Optional default keyring forwarded to every load. */
keyring?: KeyringProvider;
/**
* Adaptive cache sizing options (Cluster C). Default behaviour is to
* construct a sizer that polls `navigator.storage.estimate()` every
* 60 s and applies 25 % of the reported quota. Set
* `adaptiveCache: { enabled: false }` to skip sizer construction
* entirely or flip the `packer-browser.cache.adaptive` flag off on
* the injected `FlagProvider`.
*/
adaptiveCache?: AdaptiveCacheOptions;
}
/** Per-load options threaded through to the loader. */
export interface PackedResourceLoadCallOptions {
/** Whether to consult the cache. Default: true. */
cache?: boolean;
/** Whether to verify the bundle checksum. Default: false. */
verifyChecksum?: boolean;
/** Optional Ed25519 public key for signature verification. */
verifyKey?: Uint8Array;
/** Optional cancellation signal. */
signal?: AbortSignal;
/** Optional retry policy. */
retryConfig?: RetryConfig;
/** Optional MIME-type prefix allow-list (see loader). */
allowedMimePrefixes?: ReadonlyArray<string>;
/** Optional per-load keyring (overrides the manager default). */
keyring?: KeyringProvider;
}
/** Preload hint passed to {@link PackedResourceManager.preload}. */
export interface PreloadHint {
/** URL to preload. */
url: string;
/** Optional Ed25519 verify key for the preload. */
verifyKey?: Uint8Array;
}
/** Events emitted by {@link PackedResourceManager}. */
export interface PackedResourceManagerEvents {
load: [{ url: string; metadata: LoadedResource["metadata"] }];
error: [{ url: string; error: Error }];
cacheHit: [{ url: string }];
cacheMiss: [{ url: string }];
cacheEvict: [{ url: string }];
[key: string | symbol]: unknown[];
}

View File

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

View File

@@ -0,0 +1,50 @@
/** Listener signature for a typed event name. */
export type EmitterListener<
TEvents extends Record<string | symbol, unknown[]>,
K extends keyof TEvents,
> = (...args: TEvents[K]) => void;
/**
* Minimal typed emitter used by {@link PackedResourceManager}. Sync,
* synchronous-dispatch, AbortSignal-unaware. The bigger
* `@suaveplan/events` `EventBus` carries policy that the manager does
* not need; this emitter is intentionally small.
*/
export class TinyEmitter<TEvents extends Record<string | symbol, unknown[]>> {
private readonly listeners = new Map<
keyof TEvents,
Set<EmitterListener<TEvents, keyof TEvents>>
>();
public on<K extends keyof TEvents>(
event: K,
listener: EmitterListener<TEvents, K>
): () => void {
let set = this.listeners.get(event);
if (set === undefined) {
set = new Set();
this.listeners.set(event, set);
}
set.add(listener as EmitterListener<TEvents, keyof TEvents>);
return () => {
set?.delete(listener as EmitterListener<TEvents, keyof TEvents>);
};
}
public off<K extends keyof TEvents>(
event: K,
listener: EmitterListener<TEvents, K>
): void {
this.listeners
.get(event)
?.delete(listener as EmitterListener<TEvents, keyof TEvents>);
}
public emit<K extends keyof TEvents>(event: K, ...args: TEvents[K]): void {
const set = this.listeners.get(event);
if (set === undefined) return;
for (const listener of set) {
(listener as EmitterListener<TEvents, K>)(...args);
}
}
}

View File

@@ -0,0 +1,7 @@
export { createPackerBrowserObservability } from "./observability.js";
export type {
PackerBrowserCounters,
PackerBrowserHistograms,
PackerBrowserObservability,
PackerBrowserObservabilityOptions,
} from "./observability.types.js";

View File

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

View File

@@ -0,0 +1,103 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPackerBrowserObservability } from "./observability.js";
function makeMeterProvider(): {
provider: MeterProvider;
exporter: InMemoryMetricExporter;
} {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
return { provider, exporter };
}
describe("createPackerBrowserObservability", () => {
it("createPackerBrowserObservability instantiates the four named instruments on the supplied Meter", {
timeout: 5000,
}, () => {
const { provider } = makeMeterProvider();
const obs = createPackerBrowserObservability({
meter: provider.getMeter("test"),
});
expect(obs.counters.fetchTotal).toBeDefined();
expect(obs.counters.cacheHitsTotal).toBeDefined();
expect(obs.counters.cacheMissTotal).toBeDefined();
expect(obs.histograms.decodeMs).toBeDefined();
expect(obs.counters.prefetchSuccessTotal).toBeDefined();
expect(obs.counters.prefetchFailureTotal).toBeDefined();
});
it("prefetchSuccessTotal and prefetchFailureTotal are emitted under a real MeterProvider", {
timeout: 30_000,
}, async () => {
const { provider, exporter } = makeMeterProvider();
const obs = createPackerBrowserObservability({
meter: provider.getMeter("prefetch-collect"),
});
obs.counters.prefetchSuccessTotal.add(1);
obs.counters.prefetchFailureTotal.add(1);
await provider.forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_prefetch_success_total");
expect(names).toContain("packed_resource_prefetch_failure_total");
});
it("short-circuits when an existing observability bundle is supplied", {
timeout: 5000,
}, () => {
const { provider } = makeMeterProvider();
const first = createPackerBrowserObservability({
meter: provider.getMeter("first"),
});
const second = createPackerBrowserObservability({
observability: first,
});
expect(second).toBe(first);
});
it("falls back to the no-op MeterProvider when no meter is supplied", {
timeout: 5000,
}, () => {
const obs = createPackerBrowserObservability();
// No-op API methods should still resolve and not throw.
expect(() => obs.counters.fetchTotal.add(1)).not.toThrow();
expect(() => obs.histograms.decodeMs.record(0)).not.toThrow();
});
it("collected counters reflect production increments via the real MeterProvider", {
timeout: 30_000,
}, async () => {
const { provider, exporter } = makeMeterProvider();
const obs = createPackerBrowserObservability({
meter: provider.getMeter("collect"),
});
obs.counters.fetchTotal.add(2);
obs.counters.cacheHitsTotal.add(3);
obs.histograms.decodeMs.record(15);
await provider.forceFlush();
const exported = exporter.getMetrics();
const allInstruments = exported
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(allInstruments).toContain("packed_resource_fetch_total");
expect(allInstruments).toContain("packed_resource_cache_hits_total");
expect(allInstruments).toContain("packed_resource_decode_ms");
});
});

View File

@@ -0,0 +1,79 @@
import { metrics, trace } from "@opentelemetry/api";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
import type {
Meter,
PackerBrowserObservability,
PackerBrowserObservabilityOptions,
Tracer,
} from "./observability.types.js";
const METER_NAME = "@suaveplan/packer-browser";
const TRACER_NAME = "@suaveplan/packer-browser";
/**
* Create a {@link PackerBrowserObservability} bundle on the supplied
* `Meter` and `Tracer`, or on the no-op providers from
* `@opentelemetry/api` when none are supplied. The returned object is
* safe to call from every public entrypoint even when no telemetry
* pipeline is wired up.
*
* | Instrument | Type | Unit |
* |-----------------------------------------|-----------|------|
* | `packed_resource_fetch_total` | counter | 1 |
* | `packed_resource_cache_hits_total` | counter | 1 |
* | `packed_resource_cache_miss_total` | counter | 1 |
* | `packed_resource_decode_ms` | histogram | ms |
*/
export function createPackerBrowserObservability(
opts?: PackerBrowserObservabilityOptions
): PackerBrowserObservability {
if (opts?.observability !== undefined) return opts.observability;
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
return {
meter,
tracer,
counters: {
fetchTotal: createCounter(
meter,
"packed_resource_fetch_total",
"Total packed-resource fetch attempts (success + failure)."
),
cacheHitsTotal: createCounter(
meter,
"packed_resource_cache_hits_total",
"Total cache hits inside PackedResourceCache.get."
),
cacheMissTotal: createCounter(
meter,
"packed_resource_cache_miss_total",
"Total cache misses inside PackedResourceCache.get."
),
prefetchSuccessTotal: createCounter(
meter,
"packed_resource_prefetch_success_total",
"Total background-fetch prefetches that completed successfully."
),
prefetchFailureTotal: createCounter(
meter,
"packed_resource_prefetch_failure_total",
"Total background-fetch prefetches that failed or aborted."
),
},
histograms: {
decodeMs: createHistogram(
meter,
"packed_resource_decode_ms",
"Wall time spent inside the loader's fetch + verify + unpack pipeline.",
"ms"
),
},
};
}
export type {
PackerBrowserCounters,
PackerBrowserHistograms,
PackerBrowserObservability,
PackerBrowserObservabilityOptions,
} from "./observability.types.js";

View File

@@ -0,0 +1,47 @@
import type {
Attributes,
Counter,
Histogram,
Meter,
Span,
Tracer,
} from "@opentelemetry/api";
export type { Attributes, Counter, Histogram, Meter, Span, Tracer };
/**
* Bundle of OpenTelemetry instruments emitted by
* `@suaveplan/packer-browser`.
*
* Every counter / histogram is created lazily on the supplied `Meter`;
* every span is opened on the supplied `Tracer`. Defaults come from the
* `@opentelemetry/api` no-op providers when no caller-supplied
* observability is wired up.
*/
export interface PackerBrowserObservability {
readonly meter: Meter;
readonly tracer: Tracer;
readonly counters: PackerBrowserCounters;
readonly histograms: PackerBrowserHistograms;
}
/** Named counters emitted by `@suaveplan/packer-browser`. */
export interface PackerBrowserCounters {
readonly fetchTotal: Counter;
readonly cacheHitsTotal: Counter;
readonly cacheMissTotal: Counter;
readonly prefetchSuccessTotal: Counter;
readonly prefetchFailureTotal: Counter;
}
/** Named histograms emitted by `@suaveplan/packer-browser`. */
export interface PackerBrowserHistograms {
readonly decodeMs: Histogram;
}
/** Optional injection bundle accepted by every public entrypoint. */
export interface PackerBrowserObservabilityOptions {
meter?: Meter;
tracer?: Tracer;
observability?: PackerBrowserObservability;
}

View File

@@ -0,0 +1,15 @@
export {
handleBackgroundFetchFailure,
handleBackgroundFetchSuccess,
prefetchPackedArchive,
} from "./prefetch.js";
export type {
BackgroundFetchEventLike,
BackgroundFetchManagerLike,
BackgroundFetchOptions,
BackgroundFetchRecordLike,
BackgroundFetchRegistrationLike,
HandleBackgroundFetchFailureOptions,
HandleBackgroundFetchSuccessOptions,
PrefetchPackedArchiveOptions,
} from "./prefetch.types.js";

View File

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

View File

@@ -0,0 +1,264 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { createServiceWorkerShim } from "../service-worker/sw-shim.js";
import {
handleBackgroundFetchFailure,
handleBackgroundFetchSuccess,
prefetchPackedArchive,
} from "./prefetch.js";
import type {
BackgroundFetchEventLike,
BackgroundFetchManagerLike,
BackgroundFetchRecordLike,
BackgroundFetchRegistrationLike,
} from "./prefetch.types.js";
function makeRegistration(
records: ReadonlyArray<BackgroundFetchRecordLike>
): BackgroundFetchRegistrationLike {
return {
id: "test-id",
matchAll: async () => records,
};
}
function makeEvent(
registration: BackgroundFetchRegistrationLike
): BackgroundFetchEventLike {
return {
registration,
waitUntil: () => undefined,
};
}
function fixedObservability(): {
observability: ReturnType<typeof createPackerBrowserObservability>;
forceFlush: () => Promise<void>;
exporter: InMemoryMetricExporter;
} {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
const observability = createPackerBrowserObservability({
meter: provider.getMeter("prefetch"),
});
return {
observability,
forceFlush: () => provider.forceFlush(),
exporter,
};
}
describe("prefetchPackedArchive", () => {
it("resolves to null when BackgroundFetchManager is unavailable", {
timeout: 5000,
}, async () => {
const result = await prefetchPackedArchive("/x.packed");
expect(result).toBeNull();
});
it("delegates to BackgroundFetchManager.fetch when the API is available", {
timeout: 5000,
}, async () => {
const calls: Array<{
id: string;
requests: ReadonlyArray<RequestInfo>;
}> = [];
const reg = makeRegistration([]);
const bg: BackgroundFetchManagerLike = {
fetch: async (id, requests, _options) => {
calls.push({ id, requests });
return reg;
},
};
const result = await prefetchPackedArchive("/a.packed", {
backgroundFetch: bg,
id: "manual-id",
options: { title: "test" },
});
expect(result).toBe(reg);
expect(calls).toEqual([{ id: "manual-id", requests: ["/a.packed"] }]);
});
it("derives a stable id from the archive URL when none is supplied", {
timeout: 5000,
}, async () => {
const observed: string[] = [];
const reg = makeRegistration([]);
const bg: BackgroundFetchManagerLike = {
fetch: async (id) => {
observed.push(id);
return reg;
},
};
await prefetchPackedArchive("/a.packed", { backgroundFetch: bg });
expect(observed[0]).toBe("packed:/a.packed");
});
it("increments prefetchFailureTotal when the underlying fetch rejects", {
timeout: 30_000,
}, async () => {
const { observability, forceFlush, exporter } = fixedObservability();
const bg: BackgroundFetchManagerLike = {
fetch: async () => {
throw new Error("network");
},
};
await expect(
prefetchPackedArchive("/a.packed", {
backgroundFetch: bg,
observability,
})
).rejects.toBeInstanceOf(Error);
await forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_prefetch_failure_total");
});
});
describe("handleBackgroundFetchSuccess", () => {
it("success handler drains records into the cache and increments prefetchSuccessTotal", {
timeout: 30_000,
}, async () => {
const { observability, forceFlush, exporter } = fixedObservability();
const shim = createServiceWorkerShim();
const request = new Request("https://cdn.example.com/a.packed");
const response = new Response("bytes", { status: 200 });
const record: BackgroundFetchRecordLike = {
request,
responseReady: Promise.resolve(response),
};
await handleBackgroundFetchSuccess(
makeEvent(makeRegistration([record])),
{
caches: shim.caches,
cacheName: "prefetch-test",
observability,
}
);
const cache = await shim.caches.open("prefetch-test");
const hit = await cache.match(request);
expect(hit).toBeDefined();
expect(await hit?.text()).toBe("bytes");
await forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_prefetch_success_total");
});
it("success handler defaults to globalThis.caches when no injection is supplied", {
timeout: 30_000,
}, async () => {
const { observability } = fixedObservability();
const shim = createServiceWorkerShim();
const originalCaches = (globalThis as { caches?: unknown }).caches;
(globalThis as { caches?: unknown }).caches = shim.caches;
try {
const request = new Request("https://cdn.example.com/g.packed");
const record: BackgroundFetchRecordLike = {
request,
responseReady: Promise.resolve(new Response("g-body")),
};
await handleBackgroundFetchSuccess(
makeEvent(makeRegistration([record])),
{
observability,
cacheName: "global-test",
}
);
const cache = await shim.caches.open("global-test");
const hit = await cache.match(request);
expect(await hit?.text()).toBe("g-body");
} finally {
(globalThis as { caches?: unknown }).caches = originalCaches;
}
});
it("success handler with no cache storage increments failure", {
timeout: 30_000,
}, async () => {
const { observability, forceFlush, exporter } = fixedObservability();
const reg = makeRegistration([]);
const originalCaches = (globalThis as { caches?: unknown }).caches;
(globalThis as { caches?: unknown }).caches = undefined;
try {
await handleBackgroundFetchSuccess(makeEvent(reg), {
observability,
});
} finally {
(globalThis as { caches?: unknown }).caches = originalCaches;
}
await forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_prefetch_failure_total");
});
it("success handler surfaces cache.put errors and counts failure", {
timeout: 30_000,
}, async () => {
const { observability } = fixedObservability();
const failingCaches = {
async open() {
throw new Error("cache open failed");
},
async has() {
return false;
},
async keys() {
return [];
},
async delete() {
return false;
},
async match() {
return undefined;
},
};
await expect(
handleBackgroundFetchSuccess(makeEvent(makeRegistration([])), {
caches: failingCaches,
observability,
})
).rejects.toThrow("cache open failed");
});
});
describe("handleBackgroundFetchFailure", () => {
it("failure handler increments prefetchFailureTotal", {
timeout: 30_000,
}, async () => {
const { observability, forceFlush, exporter } = fixedObservability();
handleBackgroundFetchFailure(makeEvent(makeRegistration([])), {
observability,
});
await forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_prefetch_failure_total");
});
});

View File

@@ -0,0 +1,110 @@
import type {
BackgroundFetchEventLike,
BackgroundFetchManagerLike,
BackgroundFetchRegistrationLike,
HandleBackgroundFetchFailureOptions,
HandleBackgroundFetchSuccessOptions,
PrefetchPackedArchiveOptions,
ShimCacheStorage,
} from "./prefetch.types.js";
const DEFAULT_CACHE_NAME = "packed-resources-v1";
function resolveBackgroundFetch(
injected: BackgroundFetchManagerLike | undefined
): BackgroundFetchManagerLike | undefined {
if (injected !== undefined) return injected;
// Production path: the page side would consult
// `await navigator.serviceWorker.ready` then read
// `registration.backgroundFetch`. Since `prefetchPackedArchive`
// is an injection-first API (the manager is always supplied in
// production by a SW glue layer), we deliberately do NOT touch
// the global here — that keeps the helper safe to call from
// non-DOM contexts and keeps the test surface deterministic.
return undefined;
}
function resolveCacheStorage(
injected: ShimCacheStorage | undefined
): ShimCacheStorage | undefined {
if (injected !== undefined) return injected;
const g = globalThis as unknown as { caches?: ShimCacheStorage };
return g.caches;
}
function deriveId(url: string): string {
return `packed:${url}`;
}
/**
* Kick off a BackgroundFetch registration for an archive URL when the
* Chromium `BackgroundFetchManager` API is available. Resolves with the
* registration on success; resolves with `null` when the API is missing
* AND no `backgroundFetch` is injected. Failures from the underlying
* `fetch()` are surfaced unchanged so callers can apply their own
* retry policy.
*/
export async function prefetchPackedArchive(
archiveUrl: string,
opts: PrefetchPackedArchiveOptions = {}
): Promise<BackgroundFetchRegistrationLike | null> {
const bg = resolveBackgroundFetch(opts.backgroundFetch);
if (bg === undefined) return null;
const id = opts.id ?? deriveId(archiveUrl);
try {
return await bg.fetch(id, [archiveUrl], opts.options);
} catch (err) {
opts.observability?.counters.prefetchFailureTotal.add(1);
throw err;
}
}
/**
* Drain a successful BackgroundFetch event's records into the
* configured cache and increment
* `PackerBrowserCounters.prefetchSuccessTotal`. Designed to be invoked
* from a Service Worker's `backgroundfetchsuccess` listener:
*
* ```ts
* scope.addEventListener("backgroundfetchsuccess", (event) =>
* event.waitUntil(handleBackgroundFetchSuccess(event, { observability }))
* );
* ```
*/
export async function handleBackgroundFetchSuccess(
event: BackgroundFetchEventLike,
opts: HandleBackgroundFetchSuccessOptions
): Promise<void> {
const caches = resolveCacheStorage(opts.caches);
const cacheName = opts.cacheName ?? DEFAULT_CACHE_NAME;
if (caches === undefined) {
opts.observability.counters.prefetchFailureTotal.add(1);
return;
}
try {
const cache = await caches.open(cacheName);
const records = await event.registration.matchAll();
for (const record of records) {
const response = await record.responseReady;
await cache.put(record.request, response.clone());
}
opts.observability.counters.prefetchSuccessTotal.add(1);
} catch (err) {
opts.observability.counters.prefetchFailureTotal.add(1);
throw err;
}
}
/**
* Increment `PackerBrowserCounters.prefetchFailureTotal` for a
* `backgroundfetchfail` / `backgroundfetchabort` event. Provided as a
* standalone helper so SW glue can register both success and failure
* listeners against the same observability bundle without rebuilding
* the counter machinery.
*/
export function handleBackgroundFetchFailure(
_event: BackgroundFetchEventLike,
opts: HandleBackgroundFetchFailureOptions
): void {
opts.observability.counters.prefetchFailureTotal.add(1);
}

View File

@@ -0,0 +1,73 @@
import type { PackerBrowserObservability } from "../observability/observability.types.js";
import type { ShimCacheStorage } from "../service-worker/sw-shim.types.js";
export type { PackerBrowserObservability, ShimCacheStorage };
/** Minimal `BackgroundFetchRegistration` surface the package depends on. */
export interface BackgroundFetchRegistrationLike {
readonly id: string;
/**
* `BackgroundFetchRegistration.matchAll()` parity. Returns the
* recorded request/response pairs.
*/
matchAll(): Promise<ReadonlyArray<BackgroundFetchRecordLike>>;
}
/** Minimal `BackgroundFetchRecord` surface. */
export interface BackgroundFetchRecordLike {
readonly request: Request;
readonly responseReady: Promise<Response>;
}
/** Minimal `BackgroundFetchManager` surface. */
export interface BackgroundFetchManagerLike {
fetch(
id: string,
requests: ReadonlyArray<RequestInfo>,
options?: BackgroundFetchOptions
): Promise<BackgroundFetchRegistrationLike>;
}
/** Options forwarded to `BackgroundFetchManager.fetch`. */
export interface BackgroundFetchOptions {
readonly title?: string;
readonly icons?: ReadonlyArray<{
readonly src: string;
readonly sizes?: string;
readonly type?: string;
}>;
readonly downloadTotal?: number;
}
/** Minimal `BackgroundFetchEvent` surface (success path). */
export interface BackgroundFetchEventLike {
readonly registration: BackgroundFetchRegistrationLike;
waitUntil(promise: Promise<unknown>): void;
}
/** Options for {@link prefetchPackedArchive}. */
export interface PrefetchPackedArchiveOptions {
/** Identifier for the BackgroundFetch registration. Default: derived from the URL. */
readonly id?: string;
/** Pluggable BackgroundFetch manager. Falls back to navigator.serviceWorker.ready.backgroundFetch when omitted. */
readonly backgroundFetch?: BackgroundFetchManagerLike;
/** Options forwarded to `BackgroundFetchManager.fetch`. */
readonly options?: BackgroundFetchOptions;
/** Optional observability bundle for counter emission on launch failures. */
readonly observability?: PackerBrowserObservability;
}
/** Options for {@link handleBackgroundFetchSuccess}. */
export interface HandleBackgroundFetchSuccessOptions {
/** Pluggable cache storage (default: `globalThis.caches`). */
readonly caches?: ShimCacheStorage;
/** Cache name to write into. Default: `"packed-resources-v1"`. */
readonly cacheName?: string;
/** Required observability bundle for counter emission. */
readonly observability: PackerBrowserObservability;
}
/** Options for {@link handleBackgroundFetchFailure}. */
export interface HandleBackgroundFetchFailureOptions {
readonly observability: PackerBrowserObservability;
}

View File

@@ -0,0 +1,8 @@
export { generatePreloadHints, injectPreloadHints } from "./preload.js";
export type {
GeneratePreloadHintsOptions,
PreloadAs,
PreloadHintLink,
PreloadManifest,
PreloadManifestEntry,
} from "./preload.types.js";

View File

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

View File

@@ -0,0 +1,182 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPackerBrowserObservability } from "../observability/observability.js";
import { generatePreloadHints, injectPreloadHints } from "./preload.js";
describe("generatePreloadHints + injectPreloadHints", () => {
it("derives hints from manifest entries marked preload: true", {
timeout: 5000,
}, () => {
const hints = generatePreloadHints({
entries: [
{ url: "/a", preload: true, as: "image" },
{ url: "/b", preload: false, as: "font" },
{ url: "/c", preload: true, as: "script" },
],
});
expect(hints.map((h) => h.url)).toEqual(["/a", "/c"]);
expect(hints[0]?.as).toBe("image");
});
it("top-N selection orders by descending priority", {
timeout: 5000,
}, () => {
const hints = generatePreloadHints(
{
entries: [
{ url: "/a", priority: 1 },
{ url: "/b", priority: 5 },
{ url: "/c", priority: 3 },
],
},
{ topN: 2 }
);
expect(hints.map((h) => h.url)).toEqual(["/b", "/c"]);
});
it("top-N selection breaks priority ties lexicographically", {
timeout: 5000,
}, () => {
const hints = generatePreloadHints(
{
entries: [
{ url: "/b", priority: 1 },
{ url: "/a", priority: 1 },
],
},
{ topN: 2 }
);
expect(hints.map((h) => h.url)).toEqual(["/a", "/b"]);
});
it("top-N clamps negative N to zero", { timeout: 5000 }, () => {
const hints = generatePreloadHints(
{ entries: [{ url: "/a", priority: 1 }] },
{ topN: -1 }
);
expect(hints).toEqual([]);
});
it("top-N treats missing priority as zero", { timeout: 5000 }, () => {
const hints = generatePreloadHints(
{
entries: [
{ url: "/a" },
{ url: "/b", priority: 1 },
{ url: "/c" },
],
},
{ topN: 3 }
);
// priority 1 ranks above missing (0); /a < /c lexicographically
expect(hints.map((h) => h.url)).toEqual(["/b", "/a", "/c"]);
});
it("entries without `as` default to fetch", { timeout: 5000 }, () => {
const hints = generatePreloadHints({
entries: [{ url: "/a", preload: true }],
});
expect(hints[0]?.as).toBe("fetch");
});
it("crossOrigin is preserved on the emitted hint", {
timeout: 5000,
}, () => {
const hints = generatePreloadHints({
entries: [
{
url: "/a",
preload: true,
as: "font",
crossOrigin: "anonymous",
},
],
});
expect(hints[0]?.crossOrigin).toBe("anonymous");
});
it("injectPreloadHints returns an empty array when the document is undefined", {
timeout: 5000,
}, () => {
const result = injectPreloadHints(undefined, [
{ url: "/a", as: "fetch" },
]);
expect(result).toEqual([]);
});
it("injectPreloadHints appends one link element per hint to document.head", {
timeout: 5000,
}, () => {
const before = document.head.querySelectorAll(
'link[rel="preload"]'
).length;
const created = injectPreloadHints(document, [
{ url: "/a", as: "image" },
{ url: "/b", as: "font", crossOrigin: "anonymous" },
]);
const after = document.head.querySelectorAll(
'link[rel="preload"]'
).length;
expect(after - before).toBe(2);
expect(created[0]?.rel).toBe("preload");
expect(created[1]?.getAttribute("crossorigin")).toBe("anonymous");
// cleanup
for (const el of created) el.remove();
});
it("injectPreloadHints handles a document with a null head gracefully", {
timeout: 5000,
}, () => {
const fakeDoc = {
head: null,
createElement: () =>
({
rel: "",
href: "",
setAttribute() {},
}) as unknown as HTMLLinkElement,
} as unknown as Document;
const result = injectPreloadHints(fakeDoc, [
{ url: "/a", as: "fetch" },
]);
expect(result).toEqual([]);
});
it("emits one fetchTotal counter increment per generated hint when observability is supplied", {
timeout: 30_000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
const observability = createPackerBrowserObservability({
meter: provider.getMeter("preload"),
});
const hints = generatePreloadHints(
{
entries: [
{ url: "/a", preload: true },
{ url: "/b", preload: true },
],
},
{ observability }
);
expect(hints.length).toBe(2);
await provider.forceFlush();
const names = exporter
.getMetrics()
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("packed_resource_fetch_total");
});
});

View File

@@ -0,0 +1,85 @@
import type {
GeneratePreloadHintsOptions,
PreloadAs,
PreloadHintLink,
PreloadManifest,
PreloadManifestEntry,
} from "./preload.types.js";
const DEFAULT_AS: PreloadAs = "fetch";
function toHint(entry: PreloadManifestEntry): PreloadHintLink {
const hint: PreloadHintLink = {
url: entry.url,
as: entry.as ?? DEFAULT_AS,
};
if (entry.crossOrigin !== undefined) {
return { ...hint, crossOrigin: entry.crossOrigin };
}
return hint;
}
/**
* Derive an ordered list of `<link rel="preload">` hints from a packed
* manifest. The default selector keeps every entry marked
* `preload: true`, in manifest order. When `opts.topN` is supplied the
* helper sorts every entry by descending `priority` (ties broken by
* lexicographic url) and emits the top `N`.
*
* Every emitted hint counts as one increment of the observability
* bundle's `fetchTotal` counter so dashboards account for preload
* traffic even before the SW intercepts the URL.
*/
export function generatePreloadHints(
manifest: PreloadManifest,
opts: GeneratePreloadHintsOptions = {}
): readonly PreloadHintLink[] {
let chosen: ReadonlyArray<PreloadManifestEntry>;
if (opts.topN !== undefined) {
const sorted = [...manifest.entries].sort((a, b) => {
const pa = a.priority ?? 0;
const pb = b.priority ?? 0;
if (pb !== pa) return pb - pa;
return a.url.localeCompare(b.url);
});
chosen = sorted.slice(0, Math.max(0, opts.topN));
} else {
chosen = manifest.entries.filter((entry) => entry.preload === true);
}
const hints = chosen.map(toHint);
if (opts.observability !== undefined) {
for (let i = 0; i < hints.length; i++) {
opts.observability.counters.fetchTotal.add(1);
}
}
return hints;
}
/**
* Append `<link rel="preload">` elements to the supplied document's
* `<head>`. Feature-detected: when `doc` is `undefined` or carries no
* `head`, the helper returns an empty array without throwing — call
* sites in SW contexts can invoke it unconditionally.
*
* Returns the array of created elements so callers can re-mount or
* inspect them in tests.
*/
export function injectPreloadHints(
doc: Document | undefined,
hints: ReadonlyArray<PreloadHintLink>
): readonly HTMLLinkElement[] {
if (doc === undefined || doc.head === null) return [];
const out: HTMLLinkElement[] = [];
for (const hint of hints) {
const link = doc.createElement("link");
link.rel = "preload";
link.href = hint.url;
link.setAttribute("as", hint.as);
if (hint.crossOrigin !== undefined) {
link.setAttribute("crossorigin", hint.crossOrigin);
}
doc.head.appendChild(link);
out.push(link);
}
return out;
}

View File

@@ -0,0 +1,66 @@
import type { PackerBrowserObservability } from "../observability/observability.types.js";
/**
* Allowed values for the `as` attribute of a `<link rel="preload">`
* element. Mirrors the HTML `RequestDestination` enum used by the
* fetch spec. Kept as a string union rather than `string` so callers
* get autocompletion.
*/
export type PreloadAs =
| "audio"
| "document"
| "embed"
| "fetch"
| "font"
| "image"
| "object"
| "script"
| "style"
| "track"
| "video"
| "worker";
/** Single manifest entry consumed by {@link generatePreloadHints}. */
export interface PreloadManifestEntry {
/** Absolute or relative URL to preload. */
readonly url: string;
/** Whether the entry is marked for preload. Default `false`. */
readonly preload?: boolean;
/**
* Optional priority for the top-N selector. Higher values win.
* Default `0`.
*/
readonly priority?: number;
/**
* `<link rel="preload">` `as` attribute. Default `"fetch"`.
*/
readonly as?: PreloadAs;
/**
* Cross-origin attribute pass-through. When supplied the resulting
* `<link>` carries `crossorigin="<value>"`.
*/
readonly crossOrigin?: "anonymous" | "use-credentials";
}
/** Manifest envelope accepted by {@link generatePreloadHints}. */
export interface PreloadManifest {
readonly entries: ReadonlyArray<PreloadManifestEntry>;
}
/** Options accepted by {@link generatePreloadHints}. */
export interface GeneratePreloadHintsOptions {
/**
* If supplied, the helper selects the top-N entries by descending
* `priority` instead of filtering by `preload: true`.
*/
readonly topN?: number;
/** Optional observability bundle for counter emission. */
readonly observability?: PackerBrowserObservability;
}
/** A pure preload hint record returned by {@link generatePreloadHints}. */
export interface PreloadHintLink {
readonly url: string;
readonly as: PreloadAs;
readonly crossOrigin?: "anonymous" | "use-credentials";
}

View File

@@ -0,0 +1,10 @@
export {
DEFAULT_RANGE_THRESHOLD_BYTES,
type ParseRangeResult,
parseRange,
serveRangeResponse,
} from "./range-response.js";
export type {
ParsedByteRange,
ServeRangeResponseOptions,
} from "./range-response.types.js";

View File

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

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DEFAULT_RANGE_THRESHOLD_BYTES,
parseRange,
serveRangeResponse,
} from "./range-response.js";
function req(range: string | null, ifRange?: string): Request {
const headers = new Headers();
if (range !== null) headers.set("range", range);
if (ifRange !== undefined) headers.set("if-range", ifRange);
return new Request("https://example/asset", { headers });
}
const big = new Uint8Array(1024).map((_, i) => i % 256);
describe("parseRange", () => {
it("returns null when header is absent", { timeout: 5000 }, () => {
expect(parseRange(null, 100)).toBeNull();
});
it("returns null on a non-bytes unit", { timeout: 5000 }, () => {
expect(parseRange("items=0-9", 100)).toBeNull();
});
it("parses a single bytes range", { timeout: 5000 }, () => {
expect(parseRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
});
it("normalises an open-ended range", { timeout: 5000 }, () => {
expect(parseRange("bytes=100-", 200)).toEqual({ start: 100, end: 199 });
});
it("normalises a suffix range", { timeout: 5000 }, () => {
expect(parseRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
});
it("clamps end to total-1 when over-shot", { timeout: 5000 }, () => {
expect(parseRange("bytes=0-9999", 100)).toEqual({ start: 0, end: 99 });
});
it("reports multi-range", { timeout: 5000 }, () => {
expect(parseRange("bytes=0-9,20-29", 100)).toBe("multi-range");
});
it("reports invalid on malformed header", { timeout: 5000 }, () => {
expect(parseRange("garbage", 100)).toBe("invalid");
expect(parseRange("bytes=abc-def", 100)).toBe("invalid");
expect(parseRange("bytes=-", 100)).toBe("invalid");
expect(parseRange("bytes=-0", 100)).toBe("invalid");
expect(parseRange("bytes=5-2", 100)).toBe("invalid");
expect(parseRange("bytes=200-300", 100)).toBe("invalid");
expect(parseRange("bytes=0-9-15", 100)).toBe("invalid");
expect(parseRange("bytes=-X", 100)).toBe("invalid");
// Open-ended range with non-numeric start (covers the rawEnd==="" branch validity check).
expect(parseRange("bytes=abc-", 100)).toBe("invalid");
expect(parseRange("bytes=-1-1", 100)).toBe("invalid");
});
});
describe("serveRangeResponse", () => {
it("yields 206 with Content-Range, Accept-Ranges, and Content-Length on a valid single range above threshold", {
timeout: 5000,
}, async () => {
const body = new Uint8Array(524288).map((_, i) => i % 256);
const res = serveRangeResponse(req("bytes=0-9"), body, {
rangeThreshold: 1024,
contentType: "audio/mpeg",
});
expect(res.status).toBe(206);
expect(res.headers.get("content-range")).toBe("bytes 0-9/524288");
expect(res.headers.get("accept-ranges")).toBe("bytes");
expect(res.headers.get("content-length")).toBe("10");
expect(res.headers.get("content-type")).toBe("audio/mpeg");
const ab = await res.arrayBuffer();
expect(ab.byteLength).toBe(10);
});
it("returns the full 200 when the body is below rangeThreshold", {
timeout: 5000,
}, async () => {
const res = serveRangeResponse(req("bytes=0-9"), big, {
rangeThreshold: 4096,
});
expect(res.status).toBe(200);
expect(res.headers.get("accept-ranges")).toBe("bytes");
expect(res.headers.get("content-length")).toBe(String(big.byteLength));
});
it("returns the full 200 when no Range header is supplied", {
timeout: 5000,
}, async () => {
const res = serveRangeResponse(req(null), big, { rangeThreshold: 1 });
expect(res.status).toBe(200);
expect(res.headers.get("accept-ranges")).toBe("bytes");
});
it("yields 416 on a multi-range request", { timeout: 5000 }, () => {
const res = serveRangeResponse(req("bytes=0-9,20-29"), big, {
rangeThreshold: 1,
});
expect(res.status).toBe(416);
expect(res.headers.get("content-range")).toBe(
`bytes */${big.byteLength}`
);
});
it("yields 416 on a malformed Range header", { timeout: 5000 }, () => {
const res = serveRangeResponse(req("bytes=abc-def"), big, {
rangeThreshold: 1,
});
expect(res.status).toBe(416);
});
it("yields 412 on If-Range etag mismatch", { timeout: 5000 }, () => {
const res = serveRangeResponse(req("bytes=0-9", '"v2"'), big, {
rangeThreshold: 1,
etag: '"v1"',
});
expect(res.status).toBe(412);
expect(res.headers.get("etag")).toBe('"v1"');
});
it("yields 206 on If-Range etag match", { timeout: 5000 }, () => {
const res = serveRangeResponse(req("bytes=0-9", '"v1"'), big, {
rangeThreshold: 1,
etag: '"v1"',
});
expect(res.status).toBe(206);
expect(res.headers.get("etag")).toBe('"v1"');
});
it("falls through to 200 on non-bytes range unit above threshold", {
timeout: 5000,
}, () => {
const res = serveRangeResponse(req("items=0-9"), big, {
rangeThreshold: 1,
});
expect(res.status).toBe(200);
});
it("uses defaults when opts is omitted entirely", { timeout: 5000 }, () => {
const res = serveRangeResponse(req(null), big);
expect(res.status).toBe(200);
expect(res.headers.get("accept-ranges")).toBe("bytes");
expect(res.headers.get("content-type")).toBe(
"application/octet-stream"
);
});
it("DEFAULT_RANGE_THRESHOLD_BYTES is 262144", { timeout: 5000 }, () => {
expect(DEFAULT_RANGE_THRESHOLD_BYTES).toBe(262144);
});
it("includes ETag on a below-threshold 200 when etag is supplied", {
timeout: 5000,
}, () => {
const res = serveRangeResponse(req(null), big, {
rangeThreshold: 1,
etag: '"v1"',
});
expect(res.headers.get("etag")).toBe('"v1"');
});
it("includes ETag on a 416 multi-range response when etag is supplied", {
timeout: 5000,
}, () => {
const res = serveRangeResponse(req("bytes=0-1,5-7"), big, {
rangeThreshold: 1,
etag: '"v9"',
});
expect(res.status).toBe(416);
expect(res.headers.get("etag")).toBe('"v9"');
});
it("includes ETag on a 206 partial-response when etag is supplied", {
timeout: 5000,
}, () => {
const res = serveRangeResponse(req("bytes=0-3"), big, {
rangeThreshold: 1,
etag: '"v7"',
});
expect(res.status).toBe(206);
expect(res.headers.get("etag")).toBe('"v7"');
});
it("falls back to 200 from the non-bytes-unit path when etag is supplied", {
timeout: 5000,
}, () => {
const res = serveRangeResponse(req("items=0-9"), big, {
rangeThreshold: 1,
etag: '"v3"',
});
expect(res.status).toBe(200);
expect(res.headers.get("etag")).toBe('"v3"');
});
it("handles suffix range above threshold", { timeout: 5000 }, async () => {
const body = new Uint8Array(8192);
for (let i = 0; i < body.byteLength; i++) body[i] = i & 0xff;
const res = serveRangeResponse(req("bytes=-100"), body, {
rangeThreshold: 1024,
});
expect(res.status).toBe(206);
expect(res.headers.get("content-range")).toBe("bytes 8092-8191/8192");
});
});

View File

@@ -0,0 +1,142 @@
import type {
ParsedByteRange,
ServeRangeResponseOptions,
} from "./range-response.types.js";
const DEFAULT_RANGE_THRESHOLD = 262144; // 256 KiB
/**
* Outcome of {@link parseRange}.
*/
export type ParseRangeResult =
| null
| "multi-range"
| "invalid"
| ParsedByteRange;
/**
* Parse a single `bytes=<start>-<end>` `Range` request header against a
* known body length. Open-ended ranges (`bytes=100-`) and suffix ranges
* (`bytes=-100`) are normalised.
*/
export function parseRange(
header: string | null,
totalBytes: number
): ParseRangeResult {
if (header === null) return null;
const m = /^([a-zA-Z]+)=(.+)$/.exec(header.trim());
if (m === null) return "invalid";
const unit = m[1] as string;
if (unit.toLowerCase() !== "bytes") return null;
const ranges = (m[2] as string).split(",").map((s) => s.trim());
if (ranges.length > 1) return "multi-range";
const single = ranges[0] as string;
const parts = single.split("-");
if (parts.length !== 2) return "invalid";
const [rawStart, rawEnd] = parts as [string, string];
if (rawStart === "" && rawEnd === "") return "invalid";
let start: number;
let end: number;
if (rawStart === "") {
const n = Number.parseInt(rawEnd, 10);
if (!Number.isFinite(n) || n <= 0) return "invalid";
start = Math.max(0, totalBytes - n);
end = totalBytes - 1;
} else if (rawEnd === "") {
start = Number.parseInt(rawStart, 10);
if (!Number.isFinite(start) || start < 0) return "invalid";
end = totalBytes - 1;
} else {
start = Number.parseInt(rawStart, 10);
end = Number.parseInt(rawEnd, 10);
if (!Number.isFinite(start) || !Number.isFinite(end)) return "invalid";
}
if (start < 0 || end < start || start >= totalBytes) return "invalid";
if (end >= totalBytes) end = totalBytes - 1;
return { start, end };
}
function bytesToBuffer(view: Uint8Array): ArrayBuffer {
const out = new ArrayBuffer(view.byteLength);
new Uint8Array(out).set(view);
return out;
}
function full200(
body: Uint8Array,
contentType: string,
acceptRanges: string,
etag: string | undefined
): Response {
return new Response(bytesToBuffer(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.byteLength),
"Accept-Ranges": acceptRanges,
...(etag !== undefined ? { ETag: etag } : {}),
},
});
}
/**
* Build a `Response` honouring RFC 7233 §2.1 single-range semantics
* over a cached body. Multi-range and malformed inputs yield `416`;
* `If-Range` mismatch yields `412`; below-threshold or absent `Range`
* yields a full `200` with `Accept-Ranges: bytes` advertised.
*/
export function serveRangeResponse(
request: Request,
body: Uint8Array,
opts: ServeRangeResponseOptions = {}
): Response {
const threshold = opts.rangeThreshold ?? DEFAULT_RANGE_THRESHOLD;
const contentType = opts.contentType ?? "application/octet-stream";
const acceptRanges = "bytes";
const rangeHeader = request.headers.get("range");
if (rangeHeader === null || body.byteLength < threshold) {
return full200(body, contentType, acceptRanges, opts.etag);
}
const ifRange = request.headers.get("if-range");
if (ifRange !== null && opts.etag !== undefined && ifRange !== opts.etag) {
return new Response(null, {
status: 412,
headers: {
"Accept-Ranges": acceptRanges,
ETag: opts.etag,
},
});
}
const parsed = parseRange(rangeHeader, body.byteLength);
if (parsed === null) {
return full200(body, contentType, acceptRanges, opts.etag);
}
if (parsed === "multi-range" || parsed === "invalid") {
return new Response(null, {
status: 416,
headers: {
"Content-Range": `bytes */${body.byteLength}`,
"Accept-Ranges": acceptRanges,
...(opts.etag !== undefined ? { ETag: opts.etag } : {}),
},
});
}
const { start, end } = parsed;
const chunk = body.subarray(start, end + 1);
return new Response(bytesToBuffer(chunk), {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${end}/${body.byteLength}`,
"Content-Length": String(chunk.byteLength),
"Accept-Ranges": acceptRanges,
...(opts.etag !== undefined ? { ETag: opts.etag } : {}),
},
});
}
/** Default range threshold in bytes (256 KiB). */
export const DEFAULT_RANGE_THRESHOLD_BYTES = DEFAULT_RANGE_THRESHOLD;

View File

@@ -0,0 +1,27 @@
/**
* Options for {@link serveRangeResponse}. Callers supply the cached
* body bytes, an optional ETag (for `If-Range` semantics), and the
* threshold below which range handling is bypassed entirely.
*/
export interface ServeRangeResponseOptions {
/** Optional MIME type for both 200 and 206 responses. */
contentType?: string;
/** Optional strong ETag used for `If-Range` matching. */
etag?: string;
/**
* Minimum body byte length at or above which the helper considers
* serving a `206 Partial Content`. Bodies smaller than this return
* a full `200` response. Default: `262144` (256 KiB).
*/
rangeThreshold?: number;
}
/**
* Parsed `Range: bytes=<start>-<end>` request. Multi-range and
* malformed requests do not reach this shape — they short-circuit to a
* `416` response in {@link parseRange}.
*/
export interface ParsedByteRange {
readonly start: number;
readonly end: number;
}

View File

@@ -0,0 +1,14 @@
export {
defaultClock,
defaultMonotonicClock,
defaultRng,
elapsedMs,
resolveRuntime,
} from "./runtime.js";
export type {
Clock,
MonotonicClock,
PackerBrowserRuntime,
Rng,
RuntimeOptions,
} from "./runtime.types.js";

View File

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

View File

@@ -0,0 +1,172 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
defaultClock,
defaultMonotonicClock,
defaultRng,
elapsedMs,
resolveRuntime,
} from "./runtime.js";
const SRC_ROOT = resolve(__dirname, "..");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
out.push(...walk(full));
} else if (
/\.ts$/.test(entry) &&
!/\.test\.ts$/.test(entry) &&
!/\.types\.ts$/.test(entry)
) {
out.push(full);
}
}
return out;
}
describe("resolveRuntime + runtime defaults", () => {
it("resolveRuntime threads through caller-supplied Clock and Rng", {
timeout: 5000,
}, () => {
const clock = (): number => 1_700_000_000_000;
const rng = {
nextUint32: () => 0xdeadbeef,
nextFloat: () => 0.5,
nextBytes: (n: number) => new Uint8Array(n).fill(7),
};
const monotonicClock = (): bigint => 42n;
const runtime = resolveRuntime({ clock, monotonicClock, rng });
expect(runtime.clock()).toBe(1_700_000_000_000);
expect(runtime.monotonicClock()).toBe(42n);
expect(runtime.rng.nextUint32()).toBe(0xdeadbeef);
expect(runtime.rng.nextBytes(4)).toEqual(new Uint8Array([7, 7, 7, 7]));
});
it("falls back to platform defaults when no options supplied", {
timeout: 5000,
}, () => {
const runtime = resolveRuntime();
expect(runtime.clock).toBe(defaultClock);
expect(runtime.monotonicClock).toBe(defaultMonotonicClock);
expect(runtime.rng).toBe(defaultRng);
});
it("upgrades a seeded Rng lacking nextBytes by deriving bytes from nextUint32", {
timeout: 5000,
}, () => {
let counter = 0;
const minimal = {
nextUint32: (): number => {
counter += 1;
return counter;
},
nextFloat: (): number => 0.5,
};
const runtime = resolveRuntime({ rng: minimal });
const bytes = runtime.rng.nextBytes(6);
expect(bytes.length).toBe(6);
// We just need non-zero bytes derived from the supplied seed.
expect(bytes.some((b) => b !== 0)).toBe(true);
});
it("derived nextBytes handles every short-tail length (1, 2, 3) without overrunning", {
timeout: 5000,
}, () => {
const minimal = {
nextUint32: (): number => 0x11_22_33_44,
nextFloat: (): number => 0,
};
const rt = resolveRuntime({ rng: minimal });
expect(rt.rng.nextBytes(1)).toEqual(new Uint8Array([0x11]));
expect(rt.rng.nextBytes(2)).toEqual(new Uint8Array([0x11, 0x22]));
expect(rt.rng.nextBytes(3)).toEqual(new Uint8Array([0x11, 0x22, 0x33]));
expect(rt.rng.nextBytes(4)).toEqual(
new Uint8Array([0x11, 0x22, 0x33, 0x44])
);
});
it("preserves nextIntInRange when the seeded Rng exposes it", {
timeout: 5000,
}, () => {
const minimal = {
nextUint32: (): number => 1,
nextFloat: (): number => 0,
nextIntInRange: (lo: number, hi: number): number => lo + hi,
};
const runtime = resolveRuntime({ rng: minimal });
expect(runtime.rng.nextIntInRange?.(2, 3)).toBe(5);
});
it("defaultClock returns a recent millisecond-epoch value", {
timeout: 5000,
}, () => {
const before = Date.now();
const sample = defaultClock();
const after = Date.now();
expect(sample).toBeGreaterThanOrEqual(before);
expect(sample).toBeLessThanOrEqual(after);
});
it("defaultMonotonicClock returns a positive bigint", {
timeout: 5000,
}, () => {
const sample = defaultMonotonicClock();
expect(typeof sample).toBe("bigint");
expect(sample > 0n).toBe(true);
});
it("defaultRng.nextBytes fills a buffer with non-zero bytes (statistically)", {
timeout: 5000,
}, () => {
const bytes = defaultRng.nextBytes(64);
expect(bytes.length).toBe(64);
expect(bytes.some((b) => b !== 0)).toBe(true);
});
it("defaultRng.nextUint32 returns a 32-bit integer", {
timeout: 5000,
}, () => {
const v = defaultRng.nextUint32();
expect(Number.isInteger(v)).toBe(true);
// 4-byte big-endian assembled via `|`, may exceed 32-bit signed; cast to >>> 0
expect(v >>> 0 >= 0).toBe(true);
});
it("defaultRng.nextFloat returns a value in [0,1)", {
timeout: 5000,
}, () => {
for (let i = 0; i < 32; i++) {
const f = defaultRng.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
}
});
it("elapsedMs converts monotonic-clock deltas to ms floats", {
timeout: 5000,
}, () => {
let t = 0n;
const monotonic = (): bigint => t;
t = 1_000_000n; // 1 ms after start (1ms = 1_000_000 ns)
const out = elapsedMs(monotonic, 0n);
expect(out).toBe(1);
});
it("no production file calls Date.now or Math.random outside runtime defaults", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT);
for (const file of files) {
// Allow this exact file (runtime/runtime.ts is the canonical home).
if (file.endsWith(`${"runtime"}/runtime.ts`)) continue;
const text = readFileSync(file, "utf8");
expect(text.includes("Date.now()")).toBe(false);
expect(text.includes("Math.random()")).toBe(false);
}
});
});

View File

@@ -0,0 +1,100 @@
import type { Clock, MonotonicClock, Rng } from "@suaveplan/types";
import type { PackerBrowserRuntime, RuntimeOptions } from "./runtime.types.js";
/** Nanoseconds per millisecond — for the default monotonic clock. */
const NANOS_PER_MS = 1_000_000;
/** Default wall-clock source. Reads `Date.now()`. */
export const defaultClock: Clock = (): number => Date.now();
/**
* Default monotonic clock. Returns `performance.now()` converted to
* nanoseconds as a `bigint`. Suitable for elapsed-time measurement; not
* suitable for human-readable timestamps.
*/
export const defaultMonotonicClock: MonotonicClock = (): bigint =>
BigInt(Math.round(performance.now() * NANOS_PER_MS));
/**
* Default cryptographic RNG. Wraps `globalThis.crypto.getRandomValues`.
*/
export const defaultRng: Required<Pick<Rng, "nextBytes">> & Rng = {
nextUint32(): number {
const buf = new Uint8Array(4);
crypto.getRandomValues(buf);
const b0 = buf[0] as number;
const b1 = buf[1] as number;
const b2 = buf[2] as number;
const b3 = buf[3] as number;
return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
},
nextFloat(): number {
const buf = new Uint8Array(4);
crypto.getRandomValues(buf);
const b0 = buf[0] as number;
const b1 = buf[1] as number;
const b2 = buf[2] as number;
const b3 = buf[3] as number;
const u32 = ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
return u32 / 0x1_0000_0000;
},
nextBytes(n: number): Uint8Array {
const out = new Uint8Array(n);
crypto.getRandomValues(out);
return out;
},
};
/**
* Adapter that upgrades an `Rng` lacking `nextBytes` into a runtime-shape
* RNG by deriving bytes from `nextUint32`.
*/
function ensureNextBytes(rng: Rng): Required<Pick<Rng, "nextBytes">> & Rng {
if (typeof rng.nextBytes === "function") {
return rng as Required<Pick<Rng, "nextBytes">> & Rng;
}
return {
nextUint32: rng.nextUint32.bind(rng),
nextFloat: rng.nextFloat.bind(rng),
...(rng.nextIntInRange !== undefined
? { nextIntInRange: rng.nextIntInRange.bind(rng) }
: {}),
nextBytes(n: number): Uint8Array {
const out = new Uint8Array(n);
let i = 0;
while (i < n) {
const word = rng.nextUint32() >>> 0;
out[i++] = (word >>> 24) & 0xff;
if (i < n) out[i++] = (word >>> 16) & 0xff;
if (i < n) out[i++] = (word >>> 8) & 0xff;
if (i < n) out[i++] = word & 0xff;
}
return out;
},
};
}
/**
* Resolve a {@link PackerBrowserRuntime} from a partial
* {@link RuntimeOptions} bundle. Every field falls back to the platform
* default when absent.
*/
export function resolveRuntime(
opts: RuntimeOptions = {}
): PackerBrowserRuntime {
const clock = opts.clock ?? defaultClock;
const monotonicClock = opts.monotonicClock ?? defaultMonotonicClock;
const rng = opts.rng !== undefined ? ensureNextBytes(opts.rng) : defaultRng;
return { clock, monotonicClock, rng };
}
/**
* Compute elapsed milliseconds from a monotonic-clock start sample.
*/
export function elapsedMs(
monotonicClock: MonotonicClock,
startNanos: bigint
): number {
const deltaNanos = monotonicClock() - startNanos;
return Number(deltaNanos) / NANOS_PER_MS;
}

View File

@@ -0,0 +1,32 @@
import type { Clock, MonotonicClock, Rng } from "@suaveplan/types";
export type { Clock, MonotonicClock, Rng };
/**
* Resolved runtime bundle — three injectable platform primitives every
* production path inside `@suaveplan/packer-browser` reads from. Created
* once per call site via {@link resolveRuntime} from the optional
* {@link RuntimeOptions} bundle threaded through the public surface.
*/
export interface PackerBrowserRuntime {
/** Wall-clock source — used for cache-entry timestamps. */
readonly clock: Clock;
/** Monotonic source — used for elapsed-ms histogram observations. */
readonly monotonicClock: MonotonicClock;
/** Random source — used for any internal randomness (cache-key salt, future Cluster B keyring). */
readonly rng: Required<Pick<Rng, "nextBytes">> & Rng;
}
/**
* Options accepted by every public surface that needs to source time and
* randomness. Each field is optional; absent fields fall back to the
* platform default in {@link resolveRuntime}.
*/
export interface RuntimeOptions {
/** Wall-clock source. Default: `() => Date.now()`. */
clock?: Clock;
/** Monotonic source. Default: wrapper around `performance.now()`. */
monotonicClock?: MonotonicClock;
/** Random source. Default: wraps `crypto.getRandomValues`. */
rng?: Rng;
}

View File

@@ -0,0 +1,15 @@
export { runPackedResourceServiceWorker } from "./service-worker.js";
export type {
CacheStatusReply,
FetchLike,
RunServiceWorkerOptions,
ServiceWorkerMessage,
ServiceWorkerShim,
} from "./service-worker.types.js";
export { createServiceWorkerShim } from "./sw-shim.js";
export type {
ShimCache,
ShimCacheStorage,
ShimClients,
ShimRecord,
} from "./sw-shim.types.js";

Some files were not shown because too many files have changed in this diff Show More