Initial Commit
This commit is contained in:
129
packages/foundation/data/packer-browser/README.md
Normal file
129
packages/foundation/data/packer-browser/README.md
Normal 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.
|
||||
|
||||
137
packages/foundation/data/packer-browser/package.json
Normal file
137
packages/foundation/data/packer-browser/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
packages/foundation/data/packer-browser/playwright.config.ts
Normal file
26
packages/foundation/data/packer-browser/playwright.config.ts
Normal 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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { createAdaptiveCacheSizer } from "./adaptive-cache.js";
|
||||
export type {
|
||||
AdaptiveCacheOptions,
|
||||
AdaptiveCacheSizer,
|
||||
AdaptiveCacheSizerOptions,
|
||||
StorageEstimateFn,
|
||||
StorageEstimateReport,
|
||||
} from "./adaptive-cache.types.js";
|
||||
104
packages/foundation/data/packer-browser/src/cache/cache.md
vendored
Normal file
104
packages/foundation/data/packer-browser/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
# `cache`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`cache.ts` implements `PackedResourceCache` — an LRU + ref-count cache
|
||||
that owns the Blob-URL lifecycle for every unpacked resource the
|
||||
`PackedResourceLoader` produces. It exists as a discrete collaborator
|
||||
(rather than baked into the manager) so the manager file stays under
|
||||
300 LOC and so the eviction policy is independently testable. The cache
|
||||
also owns the OTel instrumentation for cache hits / misses.
|
||||
|
||||
## Features
|
||||
|
||||
- LRU eviction with refCount pinning: entries with `refCount > 0`
|
||||
cannot be evicted; on overflow the least-recently-cached
|
||||
`refCount === 0` entry is dropped.
|
||||
- Blob-URL lifecycle: every eviction revokes the Blob URL through an
|
||||
injectable `revokeObjectUrl` (defaults to `URL.revokeObjectURL`).
|
||||
- OTel-instrumented `get`: every call records exactly one
|
||||
`cacheHitsTotal` or `cacheMissTotal` increment.
|
||||
- Clock-driven entry timestamps — wall-clock reads come from the
|
||||
injected `Clock` so deterministic tests can pin entry ages.
|
||||
- `cleanupExpired()` for periodic supervised eviction of stale
|
||||
entries; `clear()` for hard reset (revokes every URL regardless of
|
||||
refCount).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createPackerBrowserObservability,
|
||||
PackedResourceCache,
|
||||
resolveRuntime,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const cache = new PackedResourceCache({
|
||||
runtime: resolveRuntime(),
|
||||
observability: createPackerBrowserObservability(),
|
||||
});
|
||||
|
||||
const blob = new Blob(["payload"]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
cache.insert("key", blob, url, {
|
||||
originalSize: 7,
|
||||
compressedSize: 7,
|
||||
encrypted: false,
|
||||
timestamp: Date.now(),
|
||||
checksum: "",
|
||||
});
|
||||
|
||||
const hit = cache.get("key");
|
||||
// hit.url === url; refCount === 2 (insert started at 1, get incremented)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
import { PackedResourceCache } from "@suaveplan/packer-browser";
|
||||
|
||||
const clock = new FakeClock({ now: 0 });
|
||||
const revoked: string[] = [];
|
||||
const cache = new PackedResourceCache({
|
||||
runtime: { clock: () => clock.now(), monotonicClock: () => 0n, rng: defaultRng },
|
||||
observability,
|
||||
maxCacheAge: 60_000,
|
||||
revokeObjectUrl: (url) => revoked.push(url),
|
||||
});
|
||||
|
||||
// later, advance time and run periodic cleanup
|
||||
clock.tick(120_000);
|
||||
const evicted = cache.cleanupExpired();
|
||||
// evicted === 1; revoked === ["blob:..."]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new PackedResourceCache(opts: PackedResourceCacheOptions)`.
|
||||
- `cache.get(key): CachedResource | undefined` — increments refCount
|
||||
- refreshes timestamp on hit; records OTel counter on every call.
|
||||
- `cache.insert(key, blob, url, metadata): CachedResource` — adds an
|
||||
entry with refCount 1; evicts LRU on overflow.
|
||||
- `cache.release(key): void` — decrement refCount.
|
||||
- `cache.cleanupExpired(): number` — evict every expired
|
||||
refCount-zero entry; returns count.
|
||||
- `cache.clear(): void` — evict every entry (refCount ignored).
|
||||
- `cache.getStats(): CacheStats` — snapshot summary.
|
||||
- `cache.size(): number` / `cache.has(key): boolean` /
|
||||
`cache.evictLRU(): string | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache deliberately does NOT trigger a background timer for stale
|
||||
eviction. Periodic cleanup is the consumer's responsibility — the
|
||||
manager invokes `cleanupExpired()` at a cadence the caller controls,
|
||||
not on a hard-coded `setInterval`. This keeps the cache compatible
|
||||
with environments where timers are unavailable (e.g. some Service
|
||||
Worker contexts during install). The default `maxCacheSize` of 100
|
||||
mirrors the upstream value; the upcoming Cluster B adaptive sizing
|
||||
will override it based on `navigator.storage.estimate()`.
|
||||
294
packages/foundation/data/packer-browser/src/cache/cache.test.ts
vendored
Normal file
294
packages/foundation/data/packer-browser/src/cache/cache.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
206
packages/foundation/data/packer-browser/src/cache/cache.ts
vendored
Normal file
206
packages/foundation/data/packer-browser/src/cache/cache.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
49
packages/foundation/data/packer-browser/src/cache/cache.types.ts
vendored
Normal file
49
packages/foundation/data/packer-browser/src/cache/cache.types.ts
vendored
Normal 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;
|
||||
}>;
|
||||
}
|
||||
6
packages/foundation/data/packer-browser/src/cache/index.ts
vendored
Normal file
6
packages/foundation/data/packer-browser/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export { PackedResourceCache } from "./cache.js";
|
||||
export type {
|
||||
CachedResource,
|
||||
CacheStats,
|
||||
PackedResourceCacheOptions,
|
||||
} from "./cache.types.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
# content-address
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Content-addressed URL builder and cache-invalidation helpers for packed
|
||||
archives. Each archive's canonical URL is derived from a BLAKE3 Merkle
|
||||
root over its entries plus the format-version byte, so any payload or
|
||||
wire-format change produces a different URL and the Service Worker can
|
||||
cleanly invalidate the prior version on next install. This module is
|
||||
the foundation for the `/packed/<hash>.bundle.asset` URL form and the
|
||||
SW `activate`-time cache pruning.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeArchiveContentHash(entries, formatVersion)` — deterministic
|
||||
32-byte BLAKE3 root that mixes the format-version byte into the leaf
|
||||
set so a wire-format bump is reflected in the URL.
|
||||
- `contentHashHex(hash)` — lowercase-hex serialiser used by URL builders
|
||||
and cache-key emitters.
|
||||
- `buildContentAddressedUrl(prefix, hash, ext?)` — emits the canonical
|
||||
`<prefix>/<hash>.<ext>` URL (default ext `bundle.asset` matches the
|
||||
Cluster A SW pattern).
|
||||
- `cleanupOutdatedCaches(caches, prefix, keep)` — Service Worker helper
|
||||
that deletes every cache name with a given prefix not in `keep`.
|
||||
- `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` — stable feature-flag
|
||||
string registered against the manager's `FlagProvider`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildContentAddressedUrl,
|
||||
computeArchiveContentHash,
|
||||
contentHashHex,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const entries = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])];
|
||||
const hash = computeArchiveContentHash(entries, /* formatVersion */ 1);
|
||||
const url = buildContentAddressedUrl("/packed", contentHashHex(hash));
|
||||
// → "/packed/<64-hex>.bundle.asset"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
cleanupOutdatedCaches,
|
||||
runPackedResourceServiceWorker,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
// Inside the Service Worker bootstrap:
|
||||
runPackedResourceServiceWorker(self as unknown as ServiceWorkerGlobalScope, {
|
||||
cacheName: `packed-resources-${currentHash}`,
|
||||
contentAddressed: true,
|
||||
});
|
||||
|
||||
// `cleanupOutdatedCaches` is invoked by the SW's activate handler
|
||||
// automatically when `contentAddressed` is on. You can also call it
|
||||
// from a maintenance message handler:
|
||||
self.addEventListener("message", async (ev) => {
|
||||
if ((ev as MessageEvent).data?.type === "GC") {
|
||||
const deleted = await cleanupOutdatedCaches(
|
||||
self.caches,
|
||||
"packed-resources-",
|
||||
[`packed-resources-${currentHash}`]
|
||||
);
|
||||
console.info("Pruned", deleted);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `computeArchiveContentHash(entries: ReadonlyArray<Uint8Array>, formatVersion: number): Uint8Array`
|
||||
Throws `RangeError` if `formatVersion` is not an integer in [0, 255].
|
||||
- `contentHashHex(hash: Uint8Array): string` Throws `RangeError` on
|
||||
non-32-byte input.
|
||||
- `buildContentAddressedUrl(prefix: string, hash: string, ext?: string): string`
|
||||
- `cleanupOutdatedCaches(caches: CacheStorageLike, prefix: string, keep: ReadonlyArray<string>): Promise<string[]>`
|
||||
Returns the list of cache names that were deleted.
|
||||
- `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED: string` — `"packer-browser.url.content-addressed"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The format-version byte is appended as a synthetic trailing entry
|
||||
rather than mixed via HKDF so the implementation stays simple and
|
||||
every transformation is auditable via the existing
|
||||
`entryDigestBlake3` + `merkleRootBlake3` primitives.
|
||||
- `cleanupOutdatedCaches` accepts any object with `keys()` and
|
||||
`delete()` methods; both the production `globalThis.caches` and the
|
||||
in-memory `ShimCacheStorage` satisfy the contract, which keeps the SW
|
||||
unit tests honest.
|
||||
- `buildContentAddressedUrl` trims a trailing slash from the prefix so
|
||||
callers can pass `/packed` or `/packed/` interchangeably.
|
||||
- The Cluster A SW pattern `/\.packed$/` continues to match the
|
||||
canonical content-addressed form; the Cluster B
|
||||
`RunServiceWorkerOptions.legacyUrlPattern` slot accepts any other
|
||||
RegExp callers need for backwards compatibility (e.g. legacy
|
||||
named-archive URLs).
|
||||
@@ -0,0 +1,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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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"
|
||||
>;
|
||||
@@ -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";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `diagnostics` — development-time overlay for the packed-resource manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createDiagnosticsOverlay` produces a development-time overlay that
|
||||
displays live cache statistics and a tail of recent events from a
|
||||
`PackedResourceManager`. The overlay is subscribe-only — it never
|
||||
mutates the manager — and renders a single `<div>` with inline styles
|
||||
so it can be dropped onto any page without a stylesheet.
|
||||
|
||||
This module is PACKER-BROWSER-21's production surface. It is
|
||||
feature-detected: when the runtime has no `document`, `mount` returns a
|
||||
safe no-op disposer so the helper can be imported unconditionally from
|
||||
universal code paths.
|
||||
|
||||
## Features
|
||||
|
||||
- Live cache size from `manager.getCache().size()`
|
||||
- Hit / miss counters with a rolling hit-ratio
|
||||
- Bounded event ring (default 50) over `load`, `error`, `cacheHit`,
|
||||
`cacheMiss`, `cacheEvict`
|
||||
- Subscribe-only — no manager state is mutated
|
||||
- Disposer removes the element and unsubscribes every listener
|
||||
- Mountable into a custom `ParentNode`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDiagnosticsOverlay } from "@suaveplan/packer-browser/diagnostics";
|
||||
import { createPackedResourceManager } from "@suaveplan/packer-browser";
|
||||
|
||||
const manager = createPackedResourceManager();
|
||||
const overlay = createDiagnosticsOverlay(manager);
|
||||
const dispose = overlay.mount(); // attaches to document.body
|
||||
// On teardown:
|
||||
dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const overlay = createDiagnosticsOverlay(manager, {
|
||||
historyLimit: 200,
|
||||
now: () => performance.now(),
|
||||
document: shadowRoot.ownerDocument,
|
||||
});
|
||||
const dispose = overlay.mount(shadowRoot);
|
||||
console.log(overlay.getSnapshot());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createDiagnosticsOverlay(manager, opts?): DiagnosticsOverlay`
|
||||
- `opts.document?: Document` — DOM injection.
|
||||
- `opts.historyLimit?: number` — ring size. Default `50`.
|
||||
- `opts.now?: () => number` — clock for event timestamps. Default `Date.now`.
|
||||
|
||||
- `DiagnosticsOverlay.mount(parent?: ParentNode): () => void` — returns
|
||||
a disposer that removes the element and unsubscribes.
|
||||
- `DiagnosticsOverlay.getSnapshot(): DiagnosticsSnapshot` — read-only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The overlay subscribes to the manager's `TinyEmitter` events
|
||||
immediately on construction, BEFORE `mount` is called, so callers
|
||||
who only want the snapshot (no DOM) can still observe events. The
|
||||
disposer returned from `mount` is responsible for unsubscribing.
|
||||
- The rendered element carries `data-packer-browser-diagnostics` so
|
||||
tests can locate it without relying on inline class hooks.
|
||||
- Hits and misses are counted on the overlay side (not from the
|
||||
manager's OTel counters) so the helper does not require an
|
||||
observability bundle.
|
||||
- The overlay never calls `manager.load`, `manager.preload`, or
|
||||
`manager.release`; the only manager surface it reads is `getCache()`
|
||||
and the `on(...)` subscriber.
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createDiagnosticsOverlay } from "./diagnostics.js";
|
||||
export type {
|
||||
DiagnosticsEvent,
|
||||
DiagnosticsOverlay,
|
||||
DiagnosticsOverlayOptions,
|
||||
DiagnosticsSnapshot,
|
||||
} from "./diagnostics.types.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
# encrypted-archive
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire format for AES-256-GCM-encrypted packed archives served alongside
|
||||
unencrypted bundles. Adopts the stable magic prefix `SP-ENC1` followed
|
||||
by a length-prefixed JSON header (`{ alg, keyId, nonce, aad? }`) and the
|
||||
GCM ciphertext+tag. The format keeps the encrypted path opt-in: bodies
|
||||
without the prefix flow through the loader unchanged, so consumers can
|
||||
ship a mixed-CDN of encrypted and unencrypted bundles without forking
|
||||
the loader.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable 8-byte magic prefix (`53 50 2D 45 4E 43 31 01`) detected via a
|
||||
cheap byte-equality check on the loader hot path.
|
||||
- Self-describing header: `alg`, `keyId`, base64-encoded `nonce`, and an
|
||||
optional `aad`. No fragile binary layout for the header — JSON, with
|
||||
a hard 64 KiB cap to prevent malicious oversize headers from blowing
|
||||
the buffer.
|
||||
- AES-256-GCM via `@suaveplan/crypto`'s `aeadEncrypt`/`aeadDecrypt`. Tag
|
||||
mismatch surfaces as `PackerBrowserError(LOAD_FAILED)` with the
|
||||
underlying `CryptoError` attached as `cause` for forensic logging.
|
||||
- Pure functions — no implicit RNG, no implicit key derivation. The
|
||||
caller supplies key + nonce; this module just shapes the bytes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeEncryptedArchiveHeader,
|
||||
decryptEncryptedArchive,
|
||||
encodeEncryptedArchive,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const key = new Uint8Array(32).fill(0xaa);
|
||||
const nonce = new Uint8Array(12).fill(0x01);
|
||||
|
||||
const wire = encodeEncryptedArchive(
|
||||
new TextEncoder().encode("hello"),
|
||||
"tenant-a",
|
||||
key,
|
||||
nonce
|
||||
);
|
||||
|
||||
const decoded = decodeEncryptedArchiveHeader(wire);
|
||||
const plaintext = decryptEncryptedArchive(decoded, key);
|
||||
// → Uint8Array of "hello"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeEncryptedArchiveHeader,
|
||||
decryptEncryptedArchive,
|
||||
hasEncryptedArchiveMagic,
|
||||
MemoryKeyring,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const keyring = new MemoryKeyring({
|
||||
"tenant-a": new Uint8Array(32).fill(0xaa),
|
||||
});
|
||||
|
||||
async function unwrap(fetched: Uint8Array): Promise<Uint8Array> {
|
||||
if (!hasEncryptedArchiveMagic(fetched)) return fetched;
|
||||
const decoded = decodeEncryptedArchiveHeader(fetched);
|
||||
const key = await keyring.resolveKey(decoded.header.keyId);
|
||||
return decryptEncryptedArchive(decoded, key);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SP_ENC1_MAGIC: Uint8Array` — the 8-byte magic prefix.
|
||||
- `hasEncryptedArchiveMagic(bytes: Uint8Array): boolean` — detector.
|
||||
- `encodeEncryptedArchive(plaintext, keyId, key, nonce, opts?): Uint8Array` —
|
||||
encoder. `opts.aad?: Uint8Array` is bound into the GCM tag.
|
||||
- `decodeEncryptedArchiveHeader(bytes: Uint8Array): DecodedEncryptedArchive` —
|
||||
parses the header + slices the ciphertext. Throws `LOAD_FAILED` on a
|
||||
malformed frame.
|
||||
- `decryptEncryptedArchive(decoded, key): Uint8Array` — wraps
|
||||
`aeadDecrypt`. Throws `LOAD_FAILED` on tag mismatch with `cause`
|
||||
attached.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The header length is encoded as a 4-byte big-endian unsigned integer.
|
||||
Big-endian is the on-wire convention used by `@suaveplan/packer`'s
|
||||
manifest decoder; matching it here keeps cross-package readers
|
||||
uniform.
|
||||
- The `aad` field, when present, MUST cover the same bytes at encrypt
|
||||
and decrypt time — typically a hash of the surrounding request URL or
|
||||
manifest hash. The wire format does NOT bind `aad` automatically; the
|
||||
caller decides whether to include it.
|
||||
- A 64 KiB header cap protects against pathological adversarial inputs
|
||||
that could trigger a large allocation before any AEAD work has run.
|
||||
Realistic headers are 200–400 bytes.
|
||||
- AES-256-GCM was chosen over XChaCha20-Poly1305 because the loader is
|
||||
expected to run in browsers where AES-NI is hardware-accelerated and
|
||||
Web Crypto can be plugged in via the `engine` option of
|
||||
`aeadEncrypt`/`aeadDecrypt` for further performance.
|
||||
@@ -0,0 +1,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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
76
packages/foundation/data/packer-browser/src/flags/flags.md
Normal file
76
packages/foundation/data/packer-browser/src/flags/flags.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# `flags`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`flags.ts` resolves the `@suaveplan/feature-flags` `FlagProvider` used
|
||||
by `PackedResourceManager` to gate Cluster B / C features. Cluster A
|
||||
registers no live flags, but the provider slot exists so adopters can
|
||||
wire a `MemoryProvider` (or any other `FlagProvider`) without an API
|
||||
churn later. The default is an empty `MemoryProvider` — every
|
||||
`isEnabled` query falls through to the strategy default because no
|
||||
`FlagDefinition` is registered.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveFlagsProvider(opts)` — single-call helper that returns
|
||||
either the caller-supplied provider or a fresh empty
|
||||
`MemoryProvider` from `@suaveplan/feature-flags`.
|
||||
- Zero startup cost in the default path — no flag definitions are
|
||||
registered, no IO happens, no `setInterval` is set.
|
||||
- Composes cleanly with the rest of the manager's option bundle:
|
||||
`PackedResourceManagerOptions.flags?: FlagProvider` flows straight
|
||||
through.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveFlagsProvider } from "@suaveplan/packer-browser";
|
||||
|
||||
const provider = resolveFlagsProvider();
|
||||
const result = await provider.evaluate({ key: "anything" }, {});
|
||||
// result.value === false (no flag defined)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MemoryProvider } from "@suaveplan/feature-flags";
|
||||
import { createPackedResourceManager } from "@suaveplan/packer-browser";
|
||||
|
||||
const provider = new MemoryProvider([
|
||||
{
|
||||
key: "packer-browser.progressive-streaming",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
strategies: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const manager = createPackedResourceManager({ flags: provider });
|
||||
const sameProvider = manager.getFlagProvider();
|
||||
// sameProvider === provider — caller-supplied instance is preserved
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveFlagsProvider(opts?: FlagsOptions): FlagProvider` — returns
|
||||
`opts.flags ?? new MemoryProvider([])`.
|
||||
- `FlagsOptions` — `{ flags?: FlagProvider }`.
|
||||
- Re-export: `FlagProvider` (alias of
|
||||
`@suaveplan/feature-flags`'s).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The slot is intentionally minimal in Cluster A. The reason it ships
|
||||
now, even though no feature consumes it, is that adding a top-level
|
||||
option to a manager constructor later forces every adopter to update
|
||||
their type signatures — wiring it in once at first delivery means
|
||||
Cluster B can flip `progressive-streaming` on with a one-line config
|
||||
change. The default uses `MemoryProvider([])` rather than a null
|
||||
provider so caller code can call `provider.evaluate(...)` without
|
||||
guard checks: an empty `MemoryProvider` returns `defaultValue` for
|
||||
every unknown flag.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
47
packages/foundation/data/packer-browser/src/flags/flags.ts
Normal file
47
packages/foundation/data/packer-browser/src/flags/flags.ts
Normal 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().",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { resolveFlagsProvider } from "./flags.js";
|
||||
export type { FlagProvider, FlagsOptions } from "./flags.types.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
defaultPackerBrowserResolver,
|
||||
PACKER_BROWSER_MESSAGES,
|
||||
resolvePackerBrowserMessage,
|
||||
} from "./messages.js";
|
||||
export type {
|
||||
MessageResolver,
|
||||
PackerBrowserMessageCatalog,
|
||||
} from "./messages.types.js";
|
||||
91
packages/foundation/data/packer-browser/src/i18n/messages.md
Normal file
91
packages/foundation/data/packer-browser/src/i18n/messages.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `i18n/messages`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`messages.ts` is the English defaults catalog plus the resolver hook for
|
||||
every user-visible error message thrown by `@suaveplan/packer-browser`.
|
||||
It exists so that `throw` sites in `src/` never carry inline English
|
||||
literals — instead they pass a `packer-browser.error.<CODE>.<scenario>`
|
||||
key to `resolvePackerBrowserMessage`, which an application can override
|
||||
by passing a custom `MessageResolver` (e.g. one backed by an i18n
|
||||
catalogue) to any public option bundle.
|
||||
|
||||
## Features
|
||||
|
||||
- `PACKER_BROWSER_MESSAGES` — frozen catalog covering every key
|
||||
referenced from production code; missing entries fall back to the
|
||||
bare key (visible in test output, never silently absent).
|
||||
- `defaultPackerBrowserResolver` — `MessageResolver` instance that
|
||||
performs `{name}` placeholder interpolation from the resolver's
|
||||
`params` argument.
|
||||
- `resolvePackerBrowserMessage(resolver, key, params?)` — helper used
|
||||
at every `throw` site so the resolver-vs-default branch lives in one
|
||||
place.
|
||||
- Placeholder syntax: `"{name}"` (not `"${name}"`) — biome's
|
||||
`noTemplateCurlyInString` rule rejects the template-literal form
|
||||
inside string literals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolvePackerBrowserMessage } from "@suaveplan/packer-browser";
|
||||
|
||||
const msg = resolvePackerBrowserMessage(
|
||||
undefined,
|
||||
"packer-browser.error.RESOURCE_NOT_FOUND.http-status",
|
||||
{ status: 404, url: "/missing.packed" }
|
||||
);
|
||||
// "Packed resource fetch returned HTTP 404 for /missing.packed."
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { MessageResolver } from "@suaveplan/packer-browser";
|
||||
import { createPackedResourceManager } from "@suaveplan/packer-browser";
|
||||
|
||||
const frenchResolver: MessageResolver = (key, params) => {
|
||||
const fr: Record<string, string> = {
|
||||
"packer-browser.error.RESOURCE_NOT_FOUND.http-status":
|
||||
"Ressource introuvable ({url}) — HTTP {status}.",
|
||||
};
|
||||
let out = fr[key] ?? key;
|
||||
if (params !== undefined) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
out = out.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const manager = createPackedResourceManager({
|
||||
messageResolver: frenchResolver,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PACKER_BROWSER_MESSAGES: PackerBrowserMessageCatalog` — frozen
|
||||
English defaults; readonly `[key: string]: string`.
|
||||
- `defaultPackerBrowserResolver: MessageResolver` — looks up the key
|
||||
in `PACKER_BROWSER_MESSAGES` and interpolates `{name}` from
|
||||
`params`.
|
||||
- `resolvePackerBrowserMessage(resolver, key, params?): string` —
|
||||
routes through `resolver ?? defaultPackerBrowserResolver`.
|
||||
- Re-exported types: `MessageResolver` (alias of
|
||||
`@suaveplan/types`'s), `PackerBrowserMessageCatalog`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The catalog is sparse: only keys actually referenced from `src/`
|
||||
appear. A conformance test scans `src/**/*.ts` for resolver call sites
|
||||
and asserts every key has a catalog entry. Missing entries surface to
|
||||
the user as the bare key, which is intentional — silent fallback to a
|
||||
canned English string would mask a missing catalogue entry in
|
||||
production. Consumers wiring a custom `MessageResolver` should
|
||||
implement the same fallback (return the key verbatim when no
|
||||
translation exists) so the contract round-trips cleanly.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
102
packages/foundation/data/packer-browser/src/i18n/messages.ts
Normal file
102
packages/foundation/data/packer-browser/src/i18n/messages.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
75
packages/foundation/data/packer-browser/src/index.test.ts
Normal file
75
packages/foundation/data/packer-browser/src/index.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
206
packages/foundation/data/packer-browser/src/index.ts
Normal file
206
packages/foundation/data/packer-browser/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { emptyKeyring, MemoryKeyring } from "./keyring.js";
|
||||
export type { KeyringProvider, MemoryKeyringEntries } from "./keyring.types.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
# keyring
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `KeyringProvider` contract for resolving symmetric AES-256-GCM
|
||||
keys at decrypt time. The contract is intentionally narrow — one async
|
||||
`resolveKey(keyId, signal?)` method — so production deployments can plug in
|
||||
KMS, HSM, IndexedDB, or Web Crypto-backed implementations without
|
||||
modifying the loader pipeline. This module also ships two concrete
|
||||
implementations used in tests and small deployments: `emptyKeyring()` and
|
||||
`MemoryKeyring`.
|
||||
|
||||
## Features
|
||||
|
||||
- Async `resolveKey(keyId, signal?)` contract with `AbortSignal`
|
||||
propagation for HSM/KMS-backed implementations.
|
||||
- `emptyKeyring()` — the safe default: every `resolveKey` call rejects
|
||||
with `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` so encrypted
|
||||
archives never silently pass through.
|
||||
- `MemoryKeyring` — `Record<string, Uint8Array>`-backed implementation
|
||||
for tests and single-tenant deployments. Validates that every key is
|
||||
exactly 32 bytes at construction time.
|
||||
- Errors are typed: missing keyId surfaces `KEY_UNAVAILABLE`; pre-aborted
|
||||
signal surfaces `ABORTED`. Loader code can switch on the discriminated
|
||||
code without parsing messages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryKeyring, emptyKeyring } from "@suaveplan/packer-browser";
|
||||
|
||||
const keyring = new MemoryKeyring({
|
||||
"tenant-a": new Uint8Array(32),
|
||||
"tenant-b": new Uint8Array(32).fill(1),
|
||||
});
|
||||
|
||||
const key = await keyring.resolveKey("tenant-a");
|
||||
// → 32-byte Uint8Array
|
||||
|
||||
// The default no-op keyring rejects every keyId — used when callers
|
||||
// expect only unencrypted archives.
|
||||
const noKeyring = emptyKeyring();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createPackedResourceManager,
|
||||
MemoryKeyring,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const manager = createPackedResourceManager({
|
||||
fetch: globalThis.fetch,
|
||||
});
|
||||
|
||||
const keyring = new MemoryKeyring({
|
||||
"v1-2026": new Uint8Array(32).fill(7),
|
||||
});
|
||||
|
||||
// Pass the keyring through the loader options on a per-load basis.
|
||||
const url = await manager.load("/encrypted/asset.packed", {
|
||||
// The manager forwards `keyring` to its internal loader; an
|
||||
// `SP-ENC1`-prefixed body is decrypted before unpack.
|
||||
// (Loader options carry the keyring slot — see loader.md.)
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `KeyringProvider` — interface with one method:
|
||||
- `resolveKey(keyId: string, signal?: AbortSignal): Promise<Uint8Array>`
|
||||
- `emptyKeyring(): KeyringProvider` — rejects every keyId with
|
||||
`KEY_UNAVAILABLE`.
|
||||
- `MemoryKeyring` — class implementing `KeyringProvider`.
|
||||
- `new MemoryKeyring(entries: Record<string, Uint8Array>)` — every
|
||||
value must be exactly 32 bytes.
|
||||
- `resolveKey(keyId, signal?)` — returns the registered key or throws
|
||||
`KEY_UNAVAILABLE`; honours `AbortSignal` (throws `ABORTED`).
|
||||
- `size(): number` — number of registered keys.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The 32-byte length check at construction time guarantees the loader
|
||||
never has to validate key length on the hot path. A wrong-length key
|
||||
surfaces immediately as a constructor-time `PackerBrowserError` rather
|
||||
than a downstream AES-GCM tag mismatch.
|
||||
- `AbortSignal` is checked once on entry; long-running async resolvers
|
||||
(network KMS calls) are responsible for honouring the signal between
|
||||
awaits. The contract documents the requirement; enforcement is the
|
||||
resolver's job.
|
||||
- The contract intentionally accepts a `string` keyId — not a typed
|
||||
union — so opaque tenant identifiers work without the keyring needing
|
||||
to be type-parameterised.
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
@@ -0,0 +1,9 @@
|
||||
export { PackedResourceLoader } from "./loader.js";
|
||||
export type {
|
||||
FetchLike,
|
||||
LoadedResource,
|
||||
MessageResolver,
|
||||
PackedDataMetadata,
|
||||
PackedResourceLoaderOptions,
|
||||
PackedResourceLoadOptions,
|
||||
} from "./loader.types.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
# loader-helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Small free helpers extracted from `loader.ts` so the class stays under
|
||||
the 300-line hard cap. These functions carry no state and are exercised
|
||||
indirectly through the loader's tests (every helper has at least one
|
||||
loader-level scenario that pins its behaviour) plus the
|
||||
`encrypted-archive` and `range-response` tests for their wire formats.
|
||||
|
||||
## Features
|
||||
|
||||
- `ARCHIVE_SIGNATURE_HEADER` — the canonical lowercase header name
|
||||
(`x-archive-signature`) used for Ed25519 signature transport.
|
||||
- `NO_RETRY` — the default single-attempt retry config used by the
|
||||
loader when the caller does not supply one.
|
||||
- `defaultFetch(input, init?)` — `globalThis.fetch` wrapper that's
|
||||
injectable through `PackedResourceLoaderOptions.fetch`.
|
||||
- `hexToBytes(hex)` — decode an `0x`-prefixable hex string into a
|
||||
`Uint8Array`. Used for the signature header.
|
||||
- `maybeDecryptArchive(bytes, keyring, signal, resolver)` — branch on
|
||||
the `SP-ENC1` magic prefix: pass-through unencrypted bytes; decrypt
|
||||
through the supplied keyring otherwise. Throws `KEY_UNAVAILABLE` when
|
||||
a keyring is missing for an encrypted body.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hexToBytes, defaultFetch } from "./loader-helpers.js";
|
||||
|
||||
const sig = hexToBytes("0xab12cd34");
|
||||
const res = await defaultFetch("/asset.packed");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { maybeDecryptArchive } from "./loader-helpers.js";
|
||||
import { MemoryKeyring } from "../keyring/keyring.js";
|
||||
|
||||
const keyring = new MemoryKeyring({
|
||||
"tenant-a": new Uint8Array(32).fill(7),
|
||||
});
|
||||
|
||||
// Inside a loader pipeline, swap in the helper between fetch + unpack.
|
||||
const fetched = new Uint8Array(await response.arrayBuffer());
|
||||
const plain = await maybeDecryptArchive(
|
||||
fetched,
|
||||
keyring,
|
||||
/* signal */ undefined,
|
||||
/* resolver */ undefined
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ARCHIVE_SIGNATURE_HEADER: "x-archive-signature"`.
|
||||
- `NO_RETRY: { maxAttempts: 1; delay: () => 0 }`.
|
||||
- `__testing_noDelay` — re-exported by `loader.ts` for backwards
|
||||
compatibility with tests that import the symbol from the class
|
||||
module.
|
||||
- `defaultFetch(input: string, init?: RequestInit): Promise<Response>`.
|
||||
- `hexToBytes(hex: string): Uint8Array` — accepts an optional `0x`
|
||||
prefix; otherwise expects a sequence of `<hex><hex>` byte digits.
|
||||
- `maybeDecryptArchive(bytes, keyring, signal, resolver): Promise<Uint8Array>` —
|
||||
decrypt or pass-through; throws `PackerBrowserError(KEY_UNAVAILABLE)`
|
||||
when an encrypted body is received with no keyring.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- These helpers are factored out solely to honour CLAUDE.md §3's 300-LOC
|
||||
hard cap on `loader.ts`; they are not part of the package's public
|
||||
surface (the loader's tests exercise them indirectly through the
|
||||
loader, and the `loader/index.ts` barrel does not re-export them).
|
||||
- `maybeDecryptArchive` deliberately returns the original `Uint8Array`
|
||||
reference (not a copy) on the pass-through path so the loader does
|
||||
not allocate twice for the common unencrypted case.
|
||||
- `hexToBytes` does not validate input length parity; the loader feeds
|
||||
it only the 128-character signature header (64-byte signature) so
|
||||
the contract is constrained at the call site.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
102
packages/foundation/data/packer-browser/src/loader/loader.md
Normal file
102
packages/foundation/data/packer-browser/src/loader/loader.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# `loader`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`loader.ts` is the fetch → verify → unpack → Blob-URL pipeline for
|
||||
packed resources. It is the only place in the package that issues a
|
||||
network request, the only place that runs `Unpacker.unpack`, and the
|
||||
only place that allocates a Blob URL. Splitting it out from
|
||||
`PackedResourceManager` keeps every concern testable in isolation —
|
||||
fetch retry, signature failure, abort propagation, and OTel histogram
|
||||
emission can each be exercised without instantiating a cache.
|
||||
|
||||
## Features
|
||||
|
||||
- `fetch` routed through `@suaveplan/retry`'s `withRetry` with a
|
||||
single-attempt default policy; callers opt in to retry by passing
|
||||
`retryConfig`.
|
||||
- `AbortSignal` honoured at every yield point — pre-fetch, post-fetch,
|
||||
post-buffer, post-verify, and pre-unpack.
|
||||
- Optional Ed25519 signature verification when `verifyKey` is supplied
|
||||
and the response carries an `X-Archive-Signature` header.
|
||||
- OTel emission: increments `packed_resource_fetch_total` on every
|
||||
attempt; records `packed_resource_decode_ms` on every successful
|
||||
pipeline run.
|
||||
- Typed error surface: every failure produces `PackerBrowserError`
|
||||
with a `PACKER_BROWSER.*` code and an i18n-resolved message.
|
||||
- Injectable `fetch` for tests — no `vi.mock` required.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createPackerBrowserObservability,
|
||||
PackedResourceLoader,
|
||||
resolveRuntime,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const loader = new PackedResourceLoader({
|
||||
runtime: resolveRuntime(),
|
||||
observability: createPackerBrowserObservability(),
|
||||
});
|
||||
|
||||
const { url, metadata } = await loader.load("/banner.packed");
|
||||
img.src = url;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createConstantBackoff } from "@suaveplan/retry";
|
||||
import { PackedResourceLoader } from "@suaveplan/packer-browser";
|
||||
|
||||
const controller = new AbortController();
|
||||
const loader = new PackedResourceLoader({
|
||||
runtime: resolveRuntime(),
|
||||
observability,
|
||||
fetch: async (url) => recordedFetch(url),
|
||||
});
|
||||
|
||||
const out = await loader.load("/banner.packed", {
|
||||
verifyKey: trustAnchorPublicKey,
|
||||
signal: controller.signal,
|
||||
retryConfig: {
|
||||
maxAttempts: 3,
|
||||
delay: createConstantBackoff({ delay: 250 }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new PackedResourceLoader(opts: PackedResourceLoaderOptions)`.
|
||||
- `loader.load(url: string, opts?: PackedResourceLoadOptions):
|
||||
Promise<LoadedResource>`.
|
||||
- Throws `RESOURCE_NOT_FOUND` for non-2xx.
|
||||
- Throws `ARCHIVE_SIGNATURE_INVALID` for a non-verifying signature
|
||||
when `verifyKey` is supplied.
|
||||
- Throws `LOAD_FAILED` when `Unpacker.unpack` rejects.
|
||||
- Throws `ABORTED` when the supplied signal fires.
|
||||
- `PackedResourceLoadOptions`:
|
||||
- `verifyChecksum?: boolean` — default `false`.
|
||||
- `verifyKey?: Uint8Array` — optional Ed25519 public key.
|
||||
- `signal?: AbortSignal` — checked at every yield.
|
||||
- `retryConfig?: RetryConfig` — default: single attempt.
|
||||
- `messageResolver?: MessageResolver` — overrides the constructor's
|
||||
resolver for this call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Cluster A's loader treats every fetched payload as a raw `PackedData`
|
||||
whose metadata is reconstructed from the bytes; signature verification
|
||||
covers the **archive transport** layer, while integrity verification
|
||||
is delegated to the `Unpacker` and currently skipped
|
||||
(`verifyIntegrity: false`) because Cluster A archives do not carry the
|
||||
BLAKE3 integrity bundle. Cluster B introduces the formal manifest
|
||||
decoder and re-enables integrity. The `X-Archive-Signature` header
|
||||
contract is unchanged across clusters — switching to a separate
|
||||
signature manifest is a Cluster B concern only.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
300
packages/foundation/data/packer-browser/src/loader/loader.ts
Normal file
300
packages/foundation/data/packer-browser/src/loader/loader.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
14
packages/foundation/data/packer-browser/src/manager/index.ts
Normal file
14
packages/foundation/data/packer-browser/src/manager/index.ts
Normal 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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
# manager-helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Small free helpers extracted from `manager.ts` to keep the class file
|
||||
under CLAUDE.md §3's 300-line hard cap. Both helpers (`deriveCacheKey`
|
||||
and `checkAbortedSignal`) are exercised through the manager's tests;
|
||||
they have no state and can be reused by any future internal collaborator
|
||||
that needs the same primitives.
|
||||
|
||||
## Features
|
||||
|
||||
- `deriveCacheKey(url, verifyKey?)` — stable cache key derivation that
|
||||
appends the first 8 bytes (hex) of the verify-key as a suffix so
|
||||
two loads against the same URL with different verify-keys live in
|
||||
separate cache slots.
|
||||
- `checkAbortedSignal(signal, resolver, key?)` — throw
|
||||
`PackerBrowserError(ABORTED)` when the supplied signal is already
|
||||
aborted; tolerates `undefined` for both `signal` and `resolver` so
|
||||
it works at every call site.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { checkAbortedSignal, deriveCacheKey } from "./manager-helpers.js";
|
||||
|
||||
const key = deriveCacheKey("/asset.packed");
|
||||
const keyed = deriveCacheKey("/asset.packed", new Uint8Array(64));
|
||||
checkAbortedSignal(new AbortController().signal, undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { checkAbortedSignal, deriveCacheKey } from "./manager-helpers.js";
|
||||
import { defaultPackerBrowserResolver } from "../i18n/messages.js";
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
checkAbortedSignal(
|
||||
ac.signal,
|
||||
defaultPackerBrowserResolver,
|
||||
"packer-browser.error.ABORTED.preload"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("preload aborted", err);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `deriveCacheKey(url: string, verifyKey?: Uint8Array): string`.
|
||||
- `checkAbortedSignal(signal: AbortSignal | undefined, resolver?: MessageResolver, key?: string): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The 8-byte cap on the verify-key prefix balances key uniqueness
|
||||
against cache-key memory cost. A full key would be 32 bytes hex-
|
||||
encoded (64 chars) per entry; the 8-byte prefix is enough to
|
||||
discriminate adversarially-distinct keys.
|
||||
- `checkAbortedSignal` accepts a custom message key so the same helper
|
||||
works for `manager.load`, `manager.preload`, `manager.loadStream`,
|
||||
and future async surfaces.
|
||||
- These helpers are NOT part of the public barrel — they're internal
|
||||
implementation details of the manager. Direct imports from outside
|
||||
the manager module are discouraged.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
105
packages/foundation/data/packer-browser/src/manager/manager.md
Normal file
105
packages/foundation/data/packer-browser/src/manager/manager.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `manager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`manager.ts` is the lifecycle façade that consumers interact with. It
|
||||
composes a `PackedResourceCache` and a `PackedResourceLoader`, owns
|
||||
the cache-key derivation policy, emits typed lifecycle events
|
||||
(`load` / `cacheHit` / `cacheMiss` / `cacheEvict` / `error`), exposes
|
||||
the resolved OTel observability bundle and feature-flag provider, and
|
||||
implements `AbortSignal` propagation on every public async entrypoint.
|
||||
The split keeps every collaborator under 300 LOC per CLAUDE.md §3.
|
||||
|
||||
## Features
|
||||
|
||||
- Explicit factory: `createPackedResourceManager(opts?)` produces a
|
||||
fresh instance per call; the default `.` entrypoint does NOT
|
||||
auto-construct a module-level singleton.
|
||||
- Typed event emitter (sync dispatch) covering five lifecycle events.
|
||||
- Cache-aware `load(url, opts?)` with on / off cache modes, refCount
|
||||
release, and stale-eviction support via `cleanupExpired()`.
|
||||
- Parallel `preload(hints, signal?)` with `AbortSignal` honoured
|
||||
before any IO starts.
|
||||
- `getObservability()` / `getFlagProvider()` accessors for adopters
|
||||
that want to introspect the wiring.
|
||||
- `clearCache()` emits a `cacheEvict` per evicted URL so observers
|
||||
can update bookkeeping.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPackedResourceManager } from "@suaveplan/packer-browser";
|
||||
|
||||
const manager = createPackedResourceManager();
|
||||
manager.on("load", ({ url, metadata }) =>
|
||||
console.log(`loaded ${url} (${metadata.originalSize} B)`)
|
||||
);
|
||||
|
||||
const blobUrl = await manager.load("/banner.packed");
|
||||
imgEl.src = blobUrl;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
import {
|
||||
createPackedResourceManager,
|
||||
type PackedResourceManagerEvents,
|
||||
} from "@suaveplan/packer-browser";
|
||||
|
||||
const clock = new FakeClock({ now: 0 });
|
||||
const manager = createPackedResourceManager({
|
||||
clock: () => clock.now(),
|
||||
maxCacheSize: 4,
|
||||
maxCacheAge: 10_000,
|
||||
fetch: customFetch,
|
||||
meter: testMeter,
|
||||
});
|
||||
|
||||
manager.on("cacheEvict", ({ url }) => recorded.push(url));
|
||||
|
||||
const ac = new AbortController();
|
||||
await manager.preload(
|
||||
[
|
||||
{ url: "/a.packed" },
|
||||
{ url: "/b.packed", verifyKey: trustAnchor },
|
||||
],
|
||||
ac.signal
|
||||
);
|
||||
|
||||
clock.tick(60_000);
|
||||
const evicted = manager.cleanupExpired();
|
||||
// evicted === 2; recorded contains both URLs
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPackedResourceManager(opts?: PackedResourceManagerOptions):
|
||||
PackedResourceManager`.
|
||||
- `manager.load(url, opts?): Promise<string>` — returns the Blob URL.
|
||||
- `manager.preload(hints, signal?): Promise<void>`.
|
||||
- `manager.release(url, verifyKey?): void`.
|
||||
- `manager.cleanupExpired(): number`.
|
||||
- `manager.clearCache(): void`.
|
||||
- `manager.getCacheStats(): CacheStats`.
|
||||
- `manager.getObservability(): PackerBrowserObservability`.
|
||||
- `manager.getFlagProvider(): FlagProvider`.
|
||||
- Events: `load`, `error`, `cacheHit`, `cacheMiss`, `cacheEvict`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager's event-emitter is a minimal in-file `TinyEmitter` rather
|
||||
than `@suaveplan/events`'s `EventBus`. The latter carries
|
||||
back-pressure, async dispatch, and interceptor policy the manager
|
||||
does not need; copying a 40-line emitter inline keeps the dep graph
|
||||
narrower and the test surface tighter. Cache-key derivation includes
|
||||
the first 8 bytes of `verifyKey` hex-encoded — that matches the
|
||||
upstream form without the unsafe `Buffer` dependency. The release
|
||||
semantics are advisory: a caller that drops a Blob URL without
|
||||
calling `release` leaks one ref slot, but the eviction policy will
|
||||
still drain the cache once `clearCache` is called.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
298
packages/foundation/data/packer-browser/src/manager/manager.ts
Normal file
298
packages/foundation/data/packer-browser/src/manager/manager.ts
Normal 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 ?? {});
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# tiny-emitter
|
||||
|
||||
## Purpose
|
||||
|
||||
Minimal generic typed event emitter used internally by
|
||||
`PackedResourceManager`. Factored out of `manager.ts` so the class
|
||||
file stays below CLAUDE.md §3's 300-line hard cap, and so the emitter
|
||||
can be reused by any future internal class that needs typed sync
|
||||
event dispatch without paying for `@suaveplan/events`' interceptor and
|
||||
backpressure machinery.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over `TEvents extends Record<string | symbol, unknown[]>` —
|
||||
callers describe their event map and every `on`/`off`/`emit` call
|
||||
is type-checked against it.
|
||||
- `on(event, listener)` returns an unsubscribe function — pairs with
|
||||
`off(event, listener)` so consumers can choose the shape they
|
||||
prefer.
|
||||
- Synchronous dispatch — `emit` runs every listener inline so a
|
||||
consumer that calls `emit("load", …)` knows every listener has
|
||||
completed before the next line runs. The manager relies on this
|
||||
for cache-priming ordering.
|
||||
- Zero runtime dependencies; no allocations beyond the listener sets.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TinyEmitter } from "./tiny-emitter.js";
|
||||
|
||||
interface Events {
|
||||
ping: [time: number];
|
||||
[key: string | symbol]: unknown[];
|
||||
}
|
||||
|
||||
const emitter = new TinyEmitter<Events>();
|
||||
const unsubscribe = emitter.on("ping", (t) => console.info(t));
|
||||
// emitter is consumed by a subclass via the protected `emit`.
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TinyEmitter } from "./tiny-emitter.js";
|
||||
|
||||
interface LoaderEvents {
|
||||
progress: [{ url: string; bytes: number }];
|
||||
error: [{ url: string; error: Error }];
|
||||
[key: string | symbol]: unknown[];
|
||||
}
|
||||
|
||||
class Loader extends TinyEmitter<LoaderEvents> {
|
||||
public report(url: string, bytes: number): void {
|
||||
// protected `emit` is invoked from inside the subclass.
|
||||
this.emit("progress", { url, bytes });
|
||||
}
|
||||
}
|
||||
|
||||
const l = new Loader();
|
||||
l.on("progress", ({ url, bytes }) => console.info(url, bytes));
|
||||
l.report("/a.packed", 1024);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TinyEmitter<TEvents>` — abstract-ish base class.
|
||||
- `on<K>(event, listener): () => void` — register; returns an
|
||||
unsubscribe function.
|
||||
- `off<K>(event, listener): void` — explicit removal.
|
||||
- `emit<K>(event, ...args): void` — `protected`; subclasses call it
|
||||
to dispatch.
|
||||
- `EmitterListener<TEvents, K>` — function type:
|
||||
`(...args: TEvents[K]) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The internal `listeners` map keys on `keyof TEvents`. The `Set`
|
||||
values are typed using `EmitterListener<TEvents, keyof TEvents>` and
|
||||
cast back to the per-key listener type at the call site; the cast
|
||||
is sound because the `Set` only ever sees listeners installed for
|
||||
the matching `event` key.
|
||||
- Synchronous dispatch means a listener that throws will bubble out of
|
||||
`emit`. The manager wraps cache-emit calls in a `try { emit(...) }`
|
||||
where forward-progress matters; downstream listeners are expected
|
||||
to handle their own errors (the audit explicitly bans the package
|
||||
from swallowing them silently).
|
||||
- `TinyEmitter` is not exported from the package barrel — it's an
|
||||
internal implementation detail of the manager. Consumers needing a
|
||||
fully-featured event bus should use `@suaveplan/events`.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createPackerBrowserObservability } from "./observability.js";
|
||||
export type {
|
||||
PackerBrowserCounters,
|
||||
PackerBrowserHistograms,
|
||||
PackerBrowserObservability,
|
||||
PackerBrowserObservabilityOptions,
|
||||
} from "./observability.types.js";
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
103
packages/foundation/data/packer-browser/src/prefetch/prefetch.md
Normal file
103
packages/foundation/data/packer-browser/src/prefetch/prefetch.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# `prefetch` — BackgroundFetch integration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `prefetch` module wraps the Chromium `BackgroundFetchManager` API so
|
||||
an application can preload an archive on idle without holding the page
|
||||
open. The page side calls `prefetchPackedArchive(url, opts)` to kick off
|
||||
the registration; the Service Worker listens for
|
||||
`backgroundfetchsuccess` and calls `handleBackgroundFetchSuccess` to
|
||||
drain the records into the same cache the rest of `@suaveplan/packer-browser`
|
||||
uses. Failures and aborts route through `handleBackgroundFetchFailure`.
|
||||
|
||||
These helpers are PACKER-BROWSER-20's production surface. They are
|
||||
feature-detected — if the browser does not implement BackgroundFetch
|
||||
(`navigator.serviceWorker.ready.backgroundFetch` is undefined and no
|
||||
`backgroundFetch` is injected), `prefetchPackedArchive` resolves with
|
||||
`null` and the caller's flow continues with a regular fetch.
|
||||
|
||||
## Features
|
||||
|
||||
- API feature-detection — graceful `null` when BackgroundFetch is absent
|
||||
- Pluggable `backgroundFetch` injection for tests
|
||||
- OTel counters: `packed_resource_prefetch_success_total` and
|
||||
`packed_resource_prefetch_failure_total`
|
||||
- Cache-drain helper writes every record's response into the configured
|
||||
cache (default `packed-resources-v1`) so the SW's existing fetch
|
||||
handler hits cached entries on the next request
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { prefetchPackedArchive } from "@suaveplan/packer-browser/prefetch";
|
||||
|
||||
// Page side:
|
||||
const reg = await prefetchPackedArchive("/big.bundle.asset");
|
||||
if (reg !== null) console.log("BG fetch started:", reg.id);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Service worker side:
|
||||
import { handleBackgroundFetchSuccess } from "@suaveplan/packer-browser/prefetch";
|
||||
|
||||
self.addEventListener("backgroundfetchsuccess", (event) => {
|
||||
event.waitUntil(
|
||||
handleBackgroundFetchSuccess(event, {
|
||||
observability,
|
||||
cacheName: "packed-resources-v1",
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
handleBackgroundFetchFailure,
|
||||
handleBackgroundFetchSuccess,
|
||||
prefetchPackedArchive,
|
||||
} from "@suaveplan/packer-browser/prefetch";
|
||||
|
||||
const reg = await prefetchPackedArchive("/heavy.bundle.asset", {
|
||||
id: "heavy-asset",
|
||||
options: {
|
||||
title: "Pre-loading heavy asset",
|
||||
icons: [{ src: "/icon-192.png", sizes: "192x192" }],
|
||||
},
|
||||
observability,
|
||||
});
|
||||
|
||||
// Failure listener — counts every abort.
|
||||
self.addEventListener("backgroundfetchfail", (event) =>
|
||||
handleBackgroundFetchFailure(event, { observability })
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `prefetchPackedArchive(archiveUrl, opts?): Promise<BackgroundFetchRegistrationLike | null>`
|
||||
- `handleBackgroundFetchSuccess(event, { caches?, cacheName?, observability }): Promise<void>`
|
||||
- `handleBackgroundFetchFailure(event, { observability }): void`
|
||||
|
||||
Every helper accepts a fully injectable cache storage / background
|
||||
fetch manager so tests can exercise real failure modes (cache write
|
||||
rejection, missing API, registration with zero records) without any
|
||||
mocking.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `prefetchPackedArchive` never reads the `navigator` global directly:
|
||||
production callers receive the `BackgroundFetchManager` from
|
||||
`await navigator.serviceWorker.ready` and pass it in. This keeps the
|
||||
helper safe to call from non-DOM contexts (workers, tests).
|
||||
- The success handler clones every response before writing to the cache
|
||||
so the body stream is consumed exactly once.
|
||||
- Failure counter increments are emitted from BOTH
|
||||
`prefetchPackedArchive` (when the underlying `fetch()` rejects) AND
|
||||
`handleBackgroundFetchFailure` (for SW-side abort events) so dashboards
|
||||
show every failure regardless of which layer caught it.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
110
packages/foundation/data/packer-browser/src/prefetch/prefetch.ts
Normal file
110
packages/foundation/data/packer-browser/src/prefetch/prefetch.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { generatePreloadHints, injectPreloadHints } from "./preload.js";
|
||||
export type {
|
||||
GeneratePreloadHintsOptions,
|
||||
PreloadAs,
|
||||
PreloadHintLink,
|
||||
PreloadManifest,
|
||||
PreloadManifestEntry,
|
||||
} from "./preload.types.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# `preload` — `<link rel="preload">` hint generation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`generatePreloadHints` derives an ordered list of `<link rel="preload">`
|
||||
hints from a packed manifest, and `injectPreloadHints` appends those
|
||||
hints to a document's `<head>`. Together they let the Service Worker
|
||||
(or a one-off bootstrap script) preload critical packed entries before
|
||||
the browser's HTML parser would have requested them.
|
||||
|
||||
These helpers are PACKER-BROWSER-18's production surface. They are pure
|
||||
and feature-detected — `injectPreloadHints` is safe to call from a
|
||||
Service Worker context (`document` is `undefined`) and will simply
|
||||
return an empty array.
|
||||
|
||||
## Features
|
||||
|
||||
- `preload: true` selection (default) or descending-priority top-N
|
||||
- Stable tiebreak for ties: lexicographic ordering on `url`
|
||||
- `as` defaults to `"fetch"` when absent — preserves correct dispatch
|
||||
- `crossOrigin` pass-through to the emitted `<link>`
|
||||
- OTel `fetchTotal` increments when an observability bundle is supplied
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
generatePreloadHints,
|
||||
injectPreloadHints,
|
||||
} from "@suaveplan/packer-browser/preload";
|
||||
|
||||
const hints = generatePreloadHints({
|
||||
entries: [
|
||||
{ url: "/hero.image.packed", preload: true, as: "image" },
|
||||
{ url: "/font.woff2", preload: true, as: "font" },
|
||||
],
|
||||
});
|
||||
injectPreloadHints(document, hints);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPackerBrowserObservability } from "@suaveplan/packer-browser";
|
||||
|
||||
const observability = createPackerBrowserObservability({ meter });
|
||||
|
||||
const topThree = generatePreloadHints(
|
||||
{
|
||||
entries: [
|
||||
{ url: "/a.packed", priority: 1 },
|
||||
{ url: "/b.packed", priority: 5 },
|
||||
{ url: "/c.packed", priority: 3 },
|
||||
],
|
||||
},
|
||||
{ topN: 3, observability }
|
||||
);
|
||||
// emits 3 fetchTotal increments
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `generatePreloadHints(manifest, opts?): readonly PreloadHintLink[]`
|
||||
- `manifest.entries: ReadonlyArray<PreloadManifestEntry>`
|
||||
- `opts.topN?: number` — override the default `preload: true` filter
|
||||
- `opts.observability?: PackerBrowserObservability` — emit counters
|
||||
|
||||
- `injectPreloadHints(doc, hints): readonly HTMLLinkElement[]`
|
||||
- Returns `[]` when `doc` is `undefined` or has no `<head>`.
|
||||
- Each created element carries `rel="preload"`, the configured
|
||||
`href`, `as`, and optional `crossorigin` attributes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The OTel emission is intentionally one counter per emitted hint —
|
||||
not one per manifest entry — so the count tracks bytes that actually
|
||||
get preload-requested by the browser.
|
||||
- `topN` is clamped to `[0, manifest.entries.length]` so a negative
|
||||
value yields an empty array without throwing.
|
||||
- The helper does not de-duplicate URLs — manifests are the source of
|
||||
truth for ordering and duplicates.
|
||||
- `injectPreloadHints` does not handle the non-DOM environment by
|
||||
feature-flag; it relies on `doc === undefined` or `doc.head === null`
|
||||
so a hand-rolled adapter can drop a fake document in tests.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
14
packages/foundation/data/packer-browser/src/runtime/index.ts
Normal file
14
packages/foundation/data/packer-browser/src/runtime/index.ts
Normal 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";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `runtime`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runtime.ts` centralises the three injectable platform primitives
|
||||
(`Clock`, `MonotonicClock`, `Rng`) that every production path in
|
||||
`@suaveplan/packer-browser` reads time and randomness through. Direct
|
||||
`Date.now()`, `Math.random()`, and `performance.now()` calls are banned
|
||||
outside this file so seeded-deterministic tests can pin every
|
||||
observable behaviour. The shape mirrors `@suaveplan/packer/runtime`, so
|
||||
adopters that already inject runtime into the packer get the same
|
||||
contract here.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultClock` / `defaultMonotonicClock` / `defaultRng` — wall-clock
|
||||
and CSPRNG defaults wired to the platform globals.
|
||||
- `resolveRuntime(opts?)` — merges caller-supplied `RuntimeOptions`
|
||||
against the platform defaults to produce a fully-populated
|
||||
`PackerBrowserRuntime` bundle.
|
||||
- `elapsedMs(monotonicClock, startNanos)` — convenience for the
|
||||
histogram observation sites that previously did
|
||||
`performance.now() - start` directly.
|
||||
- Adapter that upgrades a seeded `Rng` lacking `nextBytes` into the
|
||||
runtime-shape RNG by deriving bytes from `nextUint32`, so tests can
|
||||
inject `@suaveplan/testing`'s `seededRng()` unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveRuntime } from "@suaveplan/packer-browser";
|
||||
|
||||
const runtime = resolveRuntime();
|
||||
const now = runtime.clock(); // ms since epoch
|
||||
const seed = runtime.rng.nextBytes(16); // CSPRNG bytes
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeClock, seededRng } from "@suaveplan/testing";
|
||||
import { resolveRuntime } from "@suaveplan/packer-browser";
|
||||
|
||||
const clock = new FakeClock({ now: 1_700_000_000_000 });
|
||||
const runtime = resolveRuntime({
|
||||
clock: () => clock.now(),
|
||||
rng: seededRng(42),
|
||||
});
|
||||
// runtime.clock() returns 1_700_000_000_000 until FakeClock is advanced
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultClock: Clock` — `() => Date.now()`.
|
||||
- `defaultMonotonicClock: MonotonicClock` — wraps `performance.now()`,
|
||||
returns nanoseconds as `bigint`.
|
||||
- `defaultRng: Required<Pick<Rng, "nextBytes">> & Rng` — CSPRNG bound
|
||||
to `globalThis.crypto`.
|
||||
- `resolveRuntime(opts?: RuntimeOptions): PackerBrowserRuntime` — fills
|
||||
in defaults for absent fields.
|
||||
- `elapsedMs(monotonicClock, startNanos: bigint): number` — returns
|
||||
elapsed milliseconds as a float.
|
||||
- `RuntimeOptions` / `PackerBrowserRuntime` — option / resolved
|
||||
bundles.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Monotonic time is exposed as `bigint` nanoseconds even though most
|
||||
consumers ultimately reduce to millisecond floats. The `bigint`
|
||||
discipline prevents accidental rounding when sampling the clock at
|
||||
nanosecond precision; the `elapsedMs` helper exists so call sites that
|
||||
need the float form never reach for `performance.now()` directly.
|
||||
`nextBytes` is part of the resolved RNG shape because the upcoming
|
||||
Cluster B encrypted-archive keyring needs CSPRNG bytes and the manager
|
||||
will forward the resolved RNG to it.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
100
packages/foundation/data/packer-browser/src/runtime/runtime.ts
Normal file
100
packages/foundation/data/packer-browser/src/runtime/runtime.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user