Initial Commit
This commit is contained in:
@@ -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 -->
|
||||
Reference in New Issue
Block a user