Initial Commit
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# enhance-packer-browser-streaming — Cluster B §4 streaming enhancements
|
||||
|
||||
**Package**: `@suaveplan/packer-browser`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature
|
||||
**Design**: not applicable (Cluster B; architecture captured inline in `tasks.md` and the spec delta, mirroring Cluster A).
|
||||
**Spec delta**: ./specs/packer-browser/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Cluster A (`2026-05-19-add-packer-browser-core`) landed the production-quality
|
||||
core (PACKER-BROWSER-1..12, 153 tests, 100/100/100/100 coverage). The upstream
|
||||
audit (`../suaveplan/enterprise/packer-browser.md` §4) enumerates ten
|
||||
feature-rich enhancements; Cluster B picks up the four "streaming" items so
|
||||
the package can serve real CDN-backed packed media: progressive (entry-by-
|
||||
entry) unpack, Range-request support inside the Service Worker, encrypted
|
||||
archives with a pluggable keyring, and content-addressed versioned archive
|
||||
URLs with automatic SW cache invalidation. These four are foundational for
|
||||
large media (audio/video) and multi-tenant deployments; remaining §4 items
|
||||
(adaptive cache, `<link rel=preload>`, multi-origin SW, BackgroundFetch,
|
||||
diagnostics overlay, additional subpath splits, full Playwright sweep) ship in
|
||||
Cluster C.
|
||||
|
||||
## What changes
|
||||
|
||||
Pure additions. Cluster A's public surface (PACKER-BROWSER-1..12) remains
|
||||
intact. The Cluster A entrypoint (default `.` export) gains five new exports;
|
||||
no removals.
|
||||
|
||||
- ADDED: `PackedResourceLoader.loadStream(inputs, opts?)` — async iterator
|
||||
that yields each `LoadedResource` as soon as its packed bytes are
|
||||
decoded, honouring `AbortSignal` at every yield.
|
||||
- ADDED: `PackedResourceManager.loadStream(inputs, opts?)` — thin pass-through
|
||||
that also writes each decoded entry into the cache as it arrives.
|
||||
- ADDED: `serveRangeResponse(request, body, opts?)` — RFC 7233 §2.1
|
||||
single-range helper returning `Response` with `206`/`200`/`412`/`416`
|
||||
semantics (`Content-Range`, `Accept-Ranges: bytes`, `Content-Length`,
|
||||
`If-Range` ETag handling).
|
||||
- ADDED: `RunServiceWorkerOptions.rangeThreshold` (default `262144` —
|
||||
256 KiB) — fetch handler emits a `206` for matching requests whose cached
|
||||
body exceeds the threshold; multi-range requests fall back to `416`.
|
||||
- ADDED: `KeyringProvider` contract — `resolveKey(keyId): Promise<Uint8Array>`
|
||||
with a default `emptyKeyring()` factory that rejects all key ids.
|
||||
- ADDED: `encodeEncryptedArchive(plaintext, keyId, key, nonce, opts?)` /
|
||||
`decodeEncryptedArchiveHeader(bytes)` — wire format with stable
|
||||
`SP-ENC1\x01` magic prefix, framed JSON header, and AES-256-GCM
|
||||
ciphertext+tag. Header carries `{ alg: "AES-256-GCM", keyId, nonce, aad? }`.
|
||||
- ADDED: `PackedResourceLoaderOptions.keyring` — when supplied, the loader
|
||||
detects the `SP-ENC1` prefix, resolves the key, AES-GCM decrypts, then
|
||||
proceeds with the standard verify+unpack pipeline.
|
||||
- ADDED: `PACKER_BROWSER_CODES.KEY_UNAVAILABLE` (new error code) +
|
||||
matching i18n keys (`packer-browser.error.KEY_UNAVAILABLE.no-key`,
|
||||
`packer-browser.error.KEY_UNAVAILABLE.decrypt-failed`).
|
||||
- ADDED: `computeArchiveContentHash(entries, formatVersion)` —
|
||||
BLAKE3 Merkle root over canonical entry digests with the format-version
|
||||
byte appended; reuses `@suaveplan/packer`'s
|
||||
`entryDigestBlake3`/`merkleRootBlake3`.
|
||||
- ADDED: `buildContentAddressedUrl(prefix, hash, ext?)` — content-addressed
|
||||
URL builder; defaults to `/packed/<hash>.bundle.asset`.
|
||||
- ADDED: `RunServiceWorkerOptions.contentAddressed` (default `true`) +
|
||||
`RunServiceWorkerOptions.legacyUrlPattern` — when content-addressed mode
|
||||
is on, SW caches under the hash; the legacy `/packed/<name>` pattern is
|
||||
still recognised when `legacyUrlPattern` is supplied.
|
||||
- ADDED: `cleanupOutdatedCaches(scope, prefix, keep)` — SW helper that
|
||||
deletes every cache whose name starts with `prefix` and is not in `keep`;
|
||||
invoked from the `activate` handler when content-addressed mode is on so
|
||||
a new manifest hash invalidates old cache entries.
|
||||
- ADDED: `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` — registered against
|
||||
the manager's injected `FlagProvider` so adopters can flip the URL form
|
||||
at runtime; default-on for new manifests, default-off honours
|
||||
legacy `/packed/<name>` requests transparently.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Adaptive cache sizing via `navigator.storage.estimate()` (§4.3) — Cluster C.
|
||||
- `<link rel="preload">` generation (§4.5) — Cluster C.
|
||||
- Multi-origin SW helper (§4.7) — Cluster C.
|
||||
- BackgroundFetch API integration (§4.8) — Cluster C.
|
||||
- Diagnostics dev overlay (§4.9) — Cluster C.
|
||||
- Subpath splits beyond Cluster A's five (`.`, `/manager`, `/service-worker`,
|
||||
`/utils`, `/singleton`) (§4.10) — Cluster C.
|
||||
- Full Playwright E2E coverage across all features — Cluster C (Cluster A's
|
||||
Playwright stub remains; new tests are vitest unit only).
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/packer-browser.md`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| PACKER-BROWSER-13 — Progressive streaming unpack | L69-L69 | §4.1 progressive streaming; per-entry yield with `AbortSignal` honouring. |
|
||||
| PACKER-BROWSER-14 — Range-request SW support | L70-L70 | §4.2 range requests; single-range 206 with `Content-Range`/`Accept-Ranges`/`If-Range`. |
|
||||
| PACKER-BROWSER-15 — Encrypted archives with pluggable keyring | L72-L72 | §4.4 encrypted archives; pluggable `KeyringProvider`, AES-256-GCM via `@suaveplan/crypto`, new `KEY_UNAVAILABLE` error code. |
|
||||
| PACKER-BROWSER-16 — Content-addressed versioned archive URLs | L74-L74 | §4.6 versioned archives; BLAKE3 hash over entry digests + format-version byte; `cleanupOutdatedCaches` from SW activate; flag-gated URL form. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement carries `**Verified by:**` entries pointing at concrete
|
||||
`it(...)` titles in the listed test files. Tests are co-located with
|
||||
implementation (`src/<feature>/<feature>.test.ts`).
|
||||
|
||||
- `src/loader/loader-stream.test.ts` — covers PACKER-BROWSER-13.
|
||||
- `src/manager/manager.test.ts` — covers PACKER-BROWSER-13 (manager pass-through).
|
||||
- `src/range-response/range-response.test.ts` — covers PACKER-BROWSER-14.
|
||||
- `src/service-worker/service-worker.test.ts` — covers PACKER-BROWSER-14 (SW wiring).
|
||||
- `src/keyring/keyring.test.ts` — covers PACKER-BROWSER-15.
|
||||
- `src/encrypted-archive/encrypted-archive.test.ts` — covers PACKER-BROWSER-15 (wire format).
|
||||
- `src/loader/loader.test.ts` — covers PACKER-BROWSER-15 (decrypt-then-unpack path).
|
||||
- `src/content-address/content-address.test.ts` — covers PACKER-BROWSER-16.
|
||||
- `src/service-worker/service-worker.test.ts` — covers PACKER-BROWSER-16 (cleanupOutdatedCaches).
|
||||
- `src/error-codes.test.ts` — covers PACKER-BROWSER-15 (new code reachability).
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/packer-browser` to enumerate.
|
||||
|
||||
- No dependents (verified by
|
||||
`grep -rln "@suaveplan/packer-browser" packages/ apps/` — the package is
|
||||
still pre-1.0 with no in-tree consumers, per Cluster A archive). All
|
||||
additions are additive; no consumer migration required.
|
||||
|
||||
### Migration
|
||||
|
||||
Not applicable — additive changes only. Existing Cluster A consumers
|
||||
continue to work unchanged; the new APIs are opt-in via additional options.
|
||||
@@ -0,0 +1,226 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: packer-browser (enhance-packer-browser-streaming)
|
||||
|
||||
**Package**: `@suaveplan/packer-browser`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. Cluster A (PACKER-BROWSER-1..12) is preserved verbatim in `openspec/specs/packer-browser/spec.md`; this delta adds PACKER-BROWSER-13..16 as `## ADDED Requirements`.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PACKER-BROWSER-13 — Progressive streaming unpack
|
||||
|
||||
The package SHALL expose `PackedResourceLoader.loadStream(inputs, opts?)`
|
||||
and `PackedResourceManager.loadStream(inputs, opts?)` that return an
|
||||
`AsyncIterable<LoadedResource>` yielding each decoded packed entry as
|
||||
soon as its bytes are fully decoded — subsequent entries MUST NOT block
|
||||
already-decoded consumers. The streaming API MUST honour an injected
|
||||
`AbortSignal` at every yield point: a pre-aborted signal MUST cause the
|
||||
iterator to throw `PackerBrowserError(PACKER_BROWSER.ABORTED)` before
|
||||
any IO runs; a signal that fires mid-stream MUST terminate the iterator
|
||||
cleanly on the next yield. The streaming path MUST coexist with the
|
||||
existing batch `load` path — it is additive, not a replacement.
|
||||
|
||||
#### Scenario: each entry is emitted as soon as it's decoded
|
||||
|
||||
- **WHEN** `loader.loadStream([a, b, c])` is consumed and entry `b`
|
||||
resolves before `a`
|
||||
- **THEN** the iterator emits `b` first (order-of-completion), then `a`,
|
||||
then `c`
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits the stream
|
||||
|
||||
- **WHEN** `loader.loadStream(inputs, { signal })` is invoked with
|
||||
`signal.aborted === true`
|
||||
- **THEN** the iterator throws
|
||||
`PackerBrowserError(PACKER_BROWSER.ABORTED)` on the first `next()` and
|
||||
no `fetch` is issued
|
||||
|
||||
#### Scenario: mid-stream abort terminates cleanly
|
||||
|
||||
- **WHEN** the signal fires after the first entry is yielded but before
|
||||
the second
|
||||
- **THEN** the iterator throws `PackerBrowserError(PACKER_BROWSER.ABORTED)`
|
||||
on the next `next()` call and no further entries are decoded
|
||||
|
||||
#### Scenario: manager streams entries through to the cache
|
||||
|
||||
- **WHEN** `manager.loadStream(inputs)` yields a `LoadedResource`
|
||||
- **THEN** the resource is inserted into the cache before the iterator
|
||||
advances to the next entry
|
||||
|
||||
**Verified by:** `src/loader/loader-stream.test.ts::"emits each entry as soon as its bytes are decoded, in order of completion"` AND `src/loader/loader-stream.test.ts::"rejects with ABORTED when the signal is pre-aborted before any fetch"` AND `src/loader/loader-stream.test.ts::"terminates cleanly when the signal fires mid-stream"` AND `src/manager/manager.test.ts::"loadStream writes each decoded entry into the cache as it arrives"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-14 — Range-request Service Worker support
|
||||
|
||||
The package SHALL expose `serveRangeResponse(request, body, opts?)` at
|
||||
`src/range-response/range-response.ts` returning a `Response` that
|
||||
honours RFC 7233 §2.1 single-range semantics: a valid single
|
||||
`bytes=<start>-<end>` range whose body length meets the configured
|
||||
`rangeThreshold` MUST yield a `206 Partial Content` carrying
|
||||
`Content-Range: bytes <start>-<end>/<total>`, `Accept-Ranges: bytes`,
|
||||
and `Content-Length: <chunk-length>` headers. A multi-range request
|
||||
(e.g. `bytes=0-9,20-29`) MUST yield `416 Range Not Satisfiable`. A
|
||||
malformed range header MUST yield `416`. An `If-Range` header carrying
|
||||
an ETag MUST yield `206` only when the etag matches the body's etag;
|
||||
on mismatch the helper MUST yield `412 Precondition Failed`. The
|
||||
Service Worker `fetch` handler MUST invoke `serveRangeResponse` for
|
||||
packed-URL requests carrying a `Range` header when the cached body
|
||||
size exceeds `RunServiceWorkerOptions.rangeThreshold` (default `262144`
|
||||
bytes = 256 KiB); below the threshold the SW MUST return the full
|
||||
200 response as in Cluster A.
|
||||
|
||||
#### Scenario: valid single range above threshold yields 206
|
||||
|
||||
- **WHEN** `serveRangeResponse` is called with a request carrying
|
||||
`Range: bytes=0-9`, a 512 KiB body, and `rangeThreshold: 1024`
|
||||
- **THEN** the response is `206`, `Content-Range: bytes 0-9/524288`,
|
||||
`Accept-Ranges: bytes`, `Content-Length: 10`, body length 10
|
||||
|
||||
#### Scenario: body below threshold returns the full 200
|
||||
|
||||
- **WHEN** the body length is below `rangeThreshold`
|
||||
- **THEN** the response is `200` with the full body and
|
||||
`Accept-Ranges: bytes` still present
|
||||
|
||||
#### Scenario: multi-range request yields 416
|
||||
|
||||
- **WHEN** the request carries `Range: bytes=0-9,20-29`
|
||||
- **THEN** the response is `416 Range Not Satisfiable`
|
||||
|
||||
#### Scenario: If-Range etag mismatch yields 412
|
||||
|
||||
- **WHEN** the request carries `If-Range: "v2"` and the cached body's
|
||||
etag is `"v1"`
|
||||
- **THEN** the response is `412 Precondition Failed`
|
||||
|
||||
#### Scenario: SW fetch handler routes ranged requests through the helper
|
||||
|
||||
- **WHEN** a packed-URL fetch event carries a `Range` header and the
|
||||
cached body exceeds `rangeThreshold`
|
||||
- **THEN** the SW responds with `206`
|
||||
|
||||
**Verified by:** `src/range-response/range-response.test.ts::"yields 206 with Content-Range, Accept-Ranges, and Content-Length on a valid single range above threshold"` AND `src/range-response/range-response.test.ts::"returns the full 200 when the body is below rangeThreshold"` AND `src/range-response/range-response.test.ts::"yields 416 on a multi-range request"` AND `src/range-response/range-response.test.ts::"yields 412 on If-Range etag mismatch"` AND `src/range-response/range-response.test.ts::"yields 416 on a malformed Range header"` AND `src/service-worker/service-worker.test.ts::"fetch handler serves a 206 partial response for a ranged packed request above rangeThreshold"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-15 — Encrypted archives with pluggable keyring
|
||||
|
||||
The package SHALL define a `KeyringProvider` contract with
|
||||
`resolveKey(keyId: string, signal?: AbortSignal): Promise<Uint8Array>`
|
||||
(throwing `PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` when the
|
||||
keyId is not resolvable) and ship two implementations:
|
||||
`emptyKeyring()` (rejects every keyId — the default so unencrypted
|
||||
archives keep working) and `MemoryKeyring` (constructed from a
|
||||
`Record<string, Uint8Array>`). The encrypted archive wire format MUST
|
||||
carry the stable magic prefix `SP-ENC1\x01` followed by a 4-byte
|
||||
big-endian header length, a UTF-8 JSON header
|
||||
`{ "alg": "AES-256-GCM", "keyId": string, "nonce": <base64>, "aad"?: <base64> }`,
|
||||
and the AES-256-GCM ciphertext+tag. The loader MUST detect the magic
|
||||
prefix on every fetched body and, when a `keyring` is supplied,
|
||||
resolve the key, AES-GCM decrypt the ciphertext, and pass the
|
||||
resulting plaintext through the standard verify+unpack pipeline. A
|
||||
missing key MUST surface `KEY_UNAVAILABLE`; a tampered ciphertext
|
||||
(GCM tag mismatch) MUST surface `LOAD_FAILED`. The default unencrypted
|
||||
path MUST continue to work unchanged when no encryption prefix is
|
||||
present.
|
||||
|
||||
#### Scenario: empty keyring rejects every keyId
|
||||
|
||||
- **WHEN** `emptyKeyring().resolveKey("k1")` is awaited
|
||||
- **THEN** the promise rejects with
|
||||
`PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: memory keyring returns its registered keys
|
||||
|
||||
- **WHEN** `new MemoryKeyring({ k1: bytesA, k2: bytesB, k3: bytesC }).resolveKey("k2")` is awaited
|
||||
- **THEN** the promise resolves with `bytesB`
|
||||
|
||||
#### Scenario: loader decrypts an encrypted archive via the keyring
|
||||
|
||||
- **WHEN** the loader fetches a body carrying the `SP-ENC1` prefix and
|
||||
is configured with a keyring that resolves the header's `keyId`
|
||||
- **THEN** the loader AES-GCM decrypts, unpacks the plaintext, and
|
||||
returns a `LoadedResource` (no error surfaced)
|
||||
|
||||
#### Scenario: missing key surfaces KEY_UNAVAILABLE
|
||||
|
||||
- **WHEN** the loader fetches an encrypted body whose keyId is not in
|
||||
the configured keyring
|
||||
- **THEN** the loader throws
|
||||
`PackerBrowserError(PACKER_BROWSER.KEY_UNAVAILABLE)` and
|
||||
`Unpacker.unpack` is not called
|
||||
|
||||
#### Scenario: tampered ciphertext surfaces LOAD_FAILED
|
||||
|
||||
- **WHEN** the loader fetches an encrypted body whose ciphertext byte 0
|
||||
has been flipped
|
||||
- **THEN** the loader throws
|
||||
`PackerBrowserError(PACKER_BROWSER.LOAD_FAILED)` whose `cause` is
|
||||
the underlying AES-GCM tag-mismatch error
|
||||
|
||||
**Verified by:** `src/keyring/keyring.test.ts::"emptyKeyring rejects every keyId with KEY_UNAVAILABLE"` AND `src/keyring/keyring.test.ts::"MemoryKeyring resolves three fixture keys and rejects an unknown id with KEY_UNAVAILABLE"` AND `src/encrypted-archive/encrypted-archive.test.ts::"round-trips plaintext through AES-256-GCM with the SP-ENC1 wire format"` AND `src/encrypted-archive/encrypted-archive.test.ts::"rejects a tampered ciphertext with a GCM tag-mismatch error"` AND `src/loader/loader.test.ts::"loader decrypts an SP-ENC1 archive via the injected keyring before unpacking"` AND `src/loader/loader.test.ts::"loader throws KEY_UNAVAILABLE when the keyring cannot resolve the keyId"` AND `src/error-codes.test.ts::"KEY_UNAVAILABLE is referenced by a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PACKER-BROWSER-16 — Content-addressed versioned archive URLs
|
||||
|
||||
The package SHALL expose `computeArchiveContentHash(entries, formatVersion)`
|
||||
that returns a BLAKE3 Merkle root over canonical entry digests with the
|
||||
single-byte `formatVersion` appended to the seed material. The
|
||||
resulting hash MUST change when any entry changes OR when the
|
||||
formatVersion byte changes. The package SHALL expose
|
||||
`buildContentAddressedUrl(prefix, hash, ext?)` returning
|
||||
`<prefix>/<hash>.<ext>` (default `ext = "bundle.asset"` so the
|
||||
canonical URL is `/packed/<hash>.bundle.asset`). The Service Worker
|
||||
SHALL accept `RunServiceWorkerOptions.contentAddressed` (default `true`)
|
||||
and `RunServiceWorkerOptions.legacyUrlPattern` (optional `RegExp`); when
|
||||
`contentAddressed` is on, the SW's `activate` handler MUST invoke
|
||||
`cleanupOutdatedCaches(scope, prefix, keep)` which deletes every cache
|
||||
whose name starts with `prefix` and is not present in `keep` — so a new
|
||||
manifest hash invalidates old cache entries on next install. When
|
||||
`legacyUrlPattern` is supplied, the `fetch` handler MUST treat matching
|
||||
URLs as packed (parity with `/packed/<name>` from Cluster A) regardless
|
||||
of the content-addressed flag. The
|
||||
`PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` flag MUST be registered
|
||||
against the manager's injected `FlagProvider` so adopters can flip the
|
||||
URL form at runtime without an API change.
|
||||
|
||||
#### Scenario: content hash is deterministic for the same entries
|
||||
|
||||
- **WHEN** `computeArchiveContentHash` is called twice with the same
|
||||
entries and the same formatVersion
|
||||
- **THEN** both calls return byte-identical 32-byte hashes
|
||||
|
||||
#### Scenario: content hash changes with formatVersion
|
||||
|
||||
- **WHEN** the same entries are hashed once with `formatVersion=1` and
|
||||
once with `formatVersion=2`
|
||||
- **THEN** the two hashes differ
|
||||
|
||||
#### Scenario: URL builder produces the canonical packed path
|
||||
|
||||
- **WHEN** `buildContentAddressedUrl("/packed", hashHex)` is called
|
||||
- **THEN** the result is `/packed/<hashHex>.bundle.asset`
|
||||
|
||||
#### Scenario: cleanupOutdatedCaches deletes outdated entries on activate
|
||||
|
||||
- **WHEN** the SW `activate` handler runs with `contentAddressed: true`
|
||||
and a current cache name list `keep = ["packed-resources-<newHash>"]`,
|
||||
and the scope already has `packed-resources-<oldHash>` cached
|
||||
- **THEN** `packed-resources-<oldHash>` is deleted and
|
||||
`packed-resources-<newHash>` is preserved
|
||||
|
||||
#### Scenario: legacy URL pattern still resolves when supplied
|
||||
|
||||
- **WHEN** the SW is configured with
|
||||
`legacyUrlPattern: /\/packed\/[a-z]+\.bundle\.asset$/` and a fetch
|
||||
event matching that pattern arrives
|
||||
- **THEN** the SW intercepts the request (treats it as packed)
|
||||
|
||||
**Verified by:** `src/content-address/content-address.test.ts::"computeArchiveContentHash is deterministic across calls with identical entries"` AND `src/content-address/content-address.test.ts::"computeArchiveContentHash differs when formatVersion changes"` AND `src/content-address/content-address.test.ts::"buildContentAddressedUrl emits the canonical /packed/<hash>.bundle.asset form"` AND `src/content-address/content-address.test.ts::"cleanupOutdatedCaches deletes caches not in the keep list and preserves caches in the keep list"` AND `src/service-worker/service-worker.test.ts::"activate handler invokes cleanupOutdatedCaches when contentAddressed is on"` AND `src/service-worker/service-worker.test.ts::"fetch handler treats legacyUrlPattern matches as packed when supplied"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,71 @@
|
||||
# Tasks — enhance-packer-browser-streaming
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/packer-browser.md` (§4 items 1, 2, 4, 6)
|
||||
- [x] 1.2 Read `openspec/specs/packer-browser/spec.md` (Cluster A — PACKER-BROWSER-1..12)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` — every requirement cites audit line range
|
||||
- [x] 1.6 Draft `specs/packer-browser/spec.md` (Cluster B delta — PACKER-BROWSER-13..16) from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 Add `KEY_UNAVAILABLE` to `src/error-codes.ts` (+ metadata) and matching i18n keys to `src/i18n/messages.ts`
|
||||
- [ ] 2.2 Implement `src/keyring/keyring.ts` — `KeyringProvider`, `emptyKeyring()`, `MemoryKeyring`
|
||||
- [ ] 2.3 Implement `src/encrypted-archive/encrypted-archive.ts` — `SP-ENC1` wire format encoder/decoder, AES-256-GCM helpers wrapping `aeadEncrypt`/`aeadDecrypt`
|
||||
- [ ] 2.4 Wire `keyring` into `PackedResourceLoader.load` (decrypt step before unpack)
|
||||
- [ ] 2.5 Implement `src/loader/loader-stream.ts` — `PackedResourceLoader.loadStream` async iterator yielding decoded entries as they arrive
|
||||
- [ ] 2.6 Wire `PackedResourceManager.loadStream` pass-through with cache insertion
|
||||
- [ ] 2.7 Implement `src/range-response/range-response.ts` — RFC 7233 §2.1 single-range parser + `Response` builder (`Content-Range`, `Accept-Ranges`, `Content-Length`, `If-Range` etag handling, 206/200/412/416)
|
||||
- [ ] 2.8 Wire `serveRangeResponse` into `service-worker.ts` `fetch` handler behind `rangeThreshold` option (default 256 KiB)
|
||||
- [ ] 2.9 Implement `src/content-address/content-address.ts` — `computeArchiveContentHash`, `buildContentAddressedUrl`, `cleanupOutdatedCaches`
|
||||
- [ ] 2.10 Wire `cleanupOutdatedCaches` into service-worker `activate` handler when `contentAddressed` is on; recognise `legacyUrlPattern` in `fetch` handler when supplied
|
||||
- [ ] 2.11 Register `PACKER_BROWSER_FLAGS.URL_CONTENT_ADDRESSED` via `flags/flags.ts` and surface from `PackedResourceManager`
|
||||
- [ ] 2.12 Add 5 new exports to `src/index.ts` barrel (explicit named per §6a)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `src/loader/loader-stream.test.ts` — yields entries as decoded; pre-aborted signal short-circuits; mid-stream abort terminates cleanly; coexists with batch `load`; order-of-completion preserved
|
||||
- [ ] 3.2 `src/manager/manager.test.ts` — manager's `loadStream` writes to cache as entries arrive (additive scenarios on the existing file; existing tests remain green)
|
||||
- [ ] 3.3 `src/range-response/range-response.test.ts` — single-range 206, full 200, threshold gate, multi-range → 416, `If-Range` matching 206, `If-Range` mismatch 412, malformed range 416, `Accept-Ranges: bytes` always present
|
||||
- [ ] 3.4 `src/service-worker/service-worker.test.ts` — additive: SW fetch handler emits 206 for ranged request above threshold; falls through to full body below threshold; `activate` invokes `cleanupOutdatedCaches`; legacy URL pattern still resolves when configured
|
||||
- [ ] 3.5 `src/keyring/keyring.test.ts` — `emptyKeyring` rejects every keyId with `KEY_UNAVAILABLE`; `MemoryKeyring` returns ≥3 fixture keys; unknown keyId rejected
|
||||
- [ ] 3.6 `src/encrypted-archive/encrypted-archive.test.ts` — round-trip AES-GCM encrypt/decrypt; magic-prefix detection; tampered ciphertext rejected; missing-key path throws `KEY_UNAVAILABLE`
|
||||
- [ ] 3.7 `src/loader/loader.test.ts` — additive: loader detects encrypted prefix, decrypts via keyring, then unpacks; missing-key surfaces `KEY_UNAVAILABLE`; tampered ciphertext surfaces `LOAD_FAILED`
|
||||
- [ ] 3.8 `src/content-address/content-address.test.ts` — `computeArchiveContentHash` deterministic for same entries; differs across format-version byte; `buildContentAddressedUrl` produces `/packed/<hash>.bundle.asset`; legacy form preserved when flag off; `cleanupOutdatedCaches` deletes everything not in keep list
|
||||
- [ ] 3.9 `src/error-codes.test.ts` — additive: `KEY_UNAVAILABLE` present + reachable from production
|
||||
- [ ] 3.10 Every `it(...)` carries `{ timeout: 5000 }` (30000 for genuinely async)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every new `.ts` (≥200 words; basic + advanced examples)
|
||||
- [ ] 4.2 Update `src/loader/loader.md`, `src/manager/manager.md`, `src/service-worker/service-worker.md`, `src/error-codes.md`, `src/i18n/messages.md` to describe Cluster B additions
|
||||
- [ ] 4.3 Update `README.md` if Quick Start surface changed (new exports listed)
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0/0/0
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/packer-browser` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/packer-browser` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/packer-browser` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/packer-browser` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary (encrypted archive header)
|
||||
- [ ] 6.3 Every error code in `error-codes.ts` thrown by ≥1 production path (incl. new `KEY_UNAVAILABLE`)
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off where applicable
|
||||
- [ ] 6.5 OTel instruments emitted from production paths (existing four still wired)
|
||||
- [ ] 6.6 Lifecycle dedup / in-flight Promise behaviour tested where claimed
|
||||
- [ ] 6.7 AbortSignal honoured at every yield in streaming path
|
||||
- [ ] 6.8 Tier purity preserved (browser-tier; no Node imports outside tests)
|
||||
- [ ] 6.9 No retroactive tick — every box reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold `specs/packer-browser/spec.md` into `openspec/specs/packer-browser/spec.md` (append PACKER-BROWSER-13..16, preserve 1..12)
|
||||
- [ ] 7.2 Move `openspec/changes/enhance-packer-browser-streaming/` → `openspec/archive/<yyyy-mm-dd>-enhance-packer-browser-streaming/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
Reference in New Issue
Block a user