Skip to content

feat(web): land the Service Worker byte pipe and app-shell precache - #951

Merged
FSM1 merged 8 commits into
mainfrom
feat/641-sw-byte-pipe-precache
Aug 2, 2026
Merged

feat(web): land the Service Worker byte pipe and app-shell precache#951
FSM1 merged 8 commits into
mainfrom
feat/641-sw-byte-pipe-precache

Conversation

@FSM1

@FSM1 FSM1 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Problem

blueprint/web-client.md resolves the #28 D4 open point by demoting the v1 decrypt Service Worker — a crypto layer doing CTR streaming over a whole buffered file — to a dumb byte pipe: a media element requests an opaque /stream/… ticket URL, the worker forwards the Range over a MessageChannel port brokered at registration, and plaintext streams back through the port. The worker holds no keys, no crypto, and no state worth keeping. The same worker precaches the app shell, because a vault you can mutate offline is a vault whose UI must boot offline.

None of it existed. There was no Service Worker, no /stream/ surface, no precache, and no ranged read to sit under any of it — leaf_range_for_byte_range shipped in #700 with zero call sites, and the engine could only return whole files.

This slice also carries the cross-cutting residual of #742/#787 (closed engine-side by #783): the browser HttpSeam did only a Content-Length pre-check, so a gateway that omits or lies about Content-Length could still force a full-body buffer in the tab — a browser-tab OOM. Desktop already enforced a true streaming peak-memory bound; web did not.

Change

A ranged plaintext read, end to end. open_content_range replaces open_content, which is now the whole-file case of it: it CID-verifies the DAG root, maps the byte window to leaf indices, fetches and unseals only those leaves, and trims. It fails closed as a trust violation if any leaf's unsealed length disagrees with what the manifest implies — a short middle leaf silently shifts every downstream byte, so that is a trust violation, not a size quirk. Preallocation is bounded by the clamped range, never the declared size. read_content_range shares the resolve, adoption gate, and head-version selection with read_content through a new head_version helper, and deliberately emits no OpProgress events: one ranged read per seek and per buffer refill would drown the event stream. downloadRange threads it through the WASM boundary and every TypeScript transport seam, including the follower broadcast path and the leader relay.

A real streaming peak-memory bound in the JS HttpSeam. FetchHttp.sendCapped mirrors crates/desktop-seams/src/http.rs: a Content-Length pre-check before a byte is read, then a ReadableStream reader drain that cancels the moment the running total would exceed maxBytes. The bound is exclusive, so a body exactly at the cap is admitted. The WASM bridge binds it as sendCapped over a { kind: 'response' | 'tooLarge' } result, fails closed on an unknown kind, and keeps a release-active body-length backstop so a buggy seam cannot talk the engine past the cap.

The byte pipe. Everything semantic — ticket lookup, Range resolution, windowing — runs tab-side in packages/client/src/media/**, where it is unit-testable without a worker; packages/client/src/sw/** is the forwarder. The worker owns only the ports it currently holds, so a killed worker or a dead port re-brokers and re-buffers. Bodies are ReadableStreams with a zero high-water mark, so peak memory is one 1 MiB window per stream, never the file. Followers route media through the leader because the port terminates at EngineClient, whose transport already swaps under them.

The app-shell precache. The apps/web build emits dist/sw.js unhashed at the output root — a worker's scope is bounded by its own URL — from a separate single-input pass, so being self-contained is structural rather than incidental; alongside it goes a precache-manifest.json of the emitted chunks. The worker caches only manifest entries, rejects cross-origin and /stream/ entries, and CacheLike structurally exposes no put, so vault data cannot reach the cache. A manifest that will not cache degrades to no offline shell, never a failed installation.

Media responses carry cache-control: no-store, x-content-type-options: nosniff, and content-security-policy: default-src 'none'; sandbox, and their Content-Type is clamped to an audio/video/image allowlist with image/svg+xml excluded: a shared file is attacker-controlled content and /stream/ is a same-origin URL.

Tests

  • The named CI gatepackages/client/test/browser/media.spec.ts, in the merge-blocking Client Browser Suite, against a real Service Worker, real fetch interception, real MessageChannel and real ReadableStream: 200 with exact bytes, 206 with a matching content-range, 416 past EOF, 404 unknown ticket, a 2.5 MiB file streaming through three windows byte-exact, port re-broker after a real Service Worker kill, and follower media routing across two real tabs on real Web Locks and a real BroadcastChannel. The kill is a genuine CDP ServiceWorker.stopAllWorkers; a negative control with the kill removed fails the assertion, so the test measures a re-broker rather than a port that never died.
  • The streaming cap — reverting the drain to await response.arrayBuffer() fails aborts at the cap when Content-Length is absent and aborts at the cap when Content-Length lies small. Both assert the produced-byte count never exceeded the cap plus one chunk, which is the peak-memory claim itself. The browser suite asserts the same against a real chunked response with no Content-Length.
  • The ranged read — engine tests including a short-middle-leaf trust rejection, a final leaf disagreeing with the declared size, an exhaustive offset x length differential against whole-file slicing, and assertions on the exact ordered CID list the scripted HTTP seam saw: only the leaves the window covers are fetched, which is the whole point of the path. A two-device integration test in write_plane.rs compares ranged reads against slices of read_content. wasm-target tests pin the capped-fetch bridge, including the unknown-kind fail-closed and the over-cap backstop.
  • The pipe and precache — Vitest suites over injected scopes, ports, and cache storage. Reverting the re-broker retry fails re-brokers a fresh port and retries the open when a port goes silent; reverting the port-replacement orphan-erroring hangs fails a body still pulling on a port that gets replaced.

Verification

All exit 0, from the worktree root:

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo check --workspace --all-targets
cargo check -p cipherbox-wasm --target wasm32-unknown-unknown --all-targets
cargo test -p cipherbox-engine
cargo test -p cipherbox-core
cargo test -p cipherbox-wasm --target wasm32-unknown-unknown
pnpm -r --if-present run typecheck
pnpm -r --if-present run test
pnpm --filter @cipherbox/client test:browser
pnpm --filter @cipherbox/web run build:bundle
pnpm exec eslint .

The build output was inspected rather than assumed: dist/sw.js sits at the root with zero import occurrences and no engine-client code, and dist/precache-manifest.json lists exactly the emitted chunks plus /index.html, with /sw.js and the manifest itself absent.

Review gates

/simplify, /security-review, and /crypto-privacy-review all ran on this diff. The crypto gate confirmed the ranged path skips no check the whole-file read performs, and accepted the encode/decode fail-closed symmetry argument for the new leaf-length invariant: ContentWriter::push seals only at exactly chunk_size and assemble already carries a release-active LinkCountMismatch, so a short middle leaf is unrepresentable rather than merely unguarded — a guard would be dead code, and the property is pinned by a release-running test instead.

Findings folded in: the media Content-Type hardening, the controlling-worker gate on ticket minting, the Rust-side capped-fetch backstop, Zeroizing on unsealed leaf plaintext, a 405 for non-GET /stream/, the delivered-length cursor advance, a pull timeout so a dead broker cannot hang a response body, per-client port routing so two open tabs stop breaking each other's streams, port-message shape validation in the worker, structural no-store on every synthesized response, the zero-length-chunk accounting guard, and a non-silent revokeStreamUrl.

Two findings were deferred, each with a dependency edge: #948 (pin the content version for the life of a stream — read_content_range re-resolves per window, so a stream can splice two versions and a large read costs one IPNS fan-out per megabyte; wants an engine-side stream handle) and #949 (the record-transport fetch is still uncapped, and credentials: 'include' goes to third-party gateways rather than being scoped to the API origin as the blueprint specifies).

No playback UI ships here — this slice lands the pipe and the registration; consuming createStreamUrl in a preview view belongs to #807. Per blueprint/web-client.md "Open edges", no web app manifest or install prompt is added: the precache is decided, installability is deployment territory.

Closes #641

Summary by CodeRabbit

  • New Features
    • Added partial media downloads by byte range across browser, worker, and multi-tab experiences.
    • Added Service Worker–based media streaming with stream URLs, range responses, recovery, and offline app-shell caching.
    • Added safeguards for oversized responses, invalid ranges, unsafe media types, and cross-origin requests.
  • Bug Fixes
    • Improved handling of interrupted streams, stale caches, unavailable Service Workers, and malformed requests.
  • Tests
    • Added extensive unit, integration, and browser coverage for range reads, streaming, caching, security, and recovery.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@FSM1, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 64219d52-0bff-4b3e-ad6d-25c5ed4cfe72

📥 Commits

Reviewing files that changed from the base of the PR and between c18a254 and 6592c0a.

📒 Files selected for processing (5)
  • crates/engine/src/content/mod.rs
  • crates/engine/src/testkit/content.rs
  • crates/engine/src/testkit/mod.rs
  • crates/engine/tests/content_wipe.rs
  • packages/client/src/seams/http.test.ts

Walkthrough

This PR adds verified ranged content reads, capped HTTP handling, client ranged-download transport, a Service Worker media pipe, app-shell precaching, and web-provider integration with browser coverage.

Changes

Ranged content and transport

Layer / File(s) Summary
Verified ranged engine reads
crates/engine/..., crates/wasm/src/host.rs
The engine reads covered leaves, validates sizes, zeroizes plaintext, and exposes ranged reads through WASM.
Capped HTTP response handling
packages/client/src/seams/*, crates/wasm/src/seams_bridge.rs
HTTP responses are capped during streaming and mapped across the WASM seam.
Client ranged-download transport
packages/client/src/{broadcast*,worker/*,transport.ts,facade.ts}
Ranged requests flow through transports, leader relay, worker messages, and engine facades.

Media Service Worker flow

Layer / File(s) Summary
Media protocol and tab service
packages/client/src/media/*
Stream tickets, range responses, media brokers, readers, and Service Worker lifecycle management are added.
Service Worker pipe and app shell
packages/client/src/sw/*
The worker streams media through brokered ports and precaches eligible app-shell assets.
Web wiring and browser validation
apps/web/src/*, apps/web/vite.config.ts, packages/client/test/browser/*
The web provider manages MediaService, while browser tests cover ranges, tabs, restarts, and leader routing.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MediaService
  participant ServiceWorker
  participant MediaPipe
  participant MediaBroker
  participant MediaReader
  MediaService->>ServiceWorker: Register worker and transfer MessagePort
  ServiceWorker->>MediaPipe: Route stream request
  MediaPipe->>MediaBroker: Open requested range
  MediaBroker->>MediaReader: downloadRange(node, offset, length)
  MediaReader-->>MediaBroker: Plaintext chunk
  MediaBroker-->>MediaPipe: Headers and chunks
  MediaPipe-->>ServiceWorker: Stream response
Loading

Possibly related issues

Possibly related PRs

  • FSM1/cipher-box#700: Introduced the engine content-read path extended here with ranged reads.
  • FSM1/cipher-box#728: Introduced worker-host and client transport surfaces extended with ranged downloads.
  • FSM1/cipher-box#883: Introduced the EngineProvider lifecycle extended here with MediaService.

Suggested labels: v2-build, comp:engine

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Service Worker byte pipe and app-shell precache, which are the primary changes.
Linked Issues check ✅ Passed The changes implement the Service Worker pipe, app-shell precache, ranged reads, capped fetches, recovery, follower routing, and required browser coverage for [#641].
Out of Scope Changes check ✅ Passed The changes remain within the linked objectives, including supporting ranged-read, capped-fetch, security, build, and test infrastructure.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/641-sw-byte-pipe-precache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@FSM1
FSM1 marked this pull request as ready for review August 2, 2026 12:51

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/providers/EngineProvider.tsx (1)

62-72: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Order the media disposal before the client disposal.

media?.dispose() is not awaited, so client.dispose() runs while the broker port can still be open. The broker reads through client (createMediaService(client) at Line 57), so an in-flight Service Worker read can resolve against a disposed client and fail.

Chain the client disposal after the media disposal settles. Note that MediaService.dispose() awaits its startup promise, so this also delays client teardown until registration settles; confirm that trade-off is acceptable.

♻️ Proposed teardown ordering
-      media?.dispose().catch((error: unknown) => {
-        console.error('[media] dispose failed', error instanceof Error ? error.message : error);
-      });
-      client.dispose().catch((error: unknown) => {
-        console.error('[engine] dispose failed', error instanceof Error ? error.message : error);
-      });
+      // The broker reads through the client, so the pipe must close first.
+      void Promise.resolve(media?.dispose())
+        .catch((error: unknown) => {
+          console.error('[media] dispose failed', error instanceof Error ? error.message : error);
+        })
+        .then(() =>
+          client.dispose().catch((error: unknown) => {
+            console.error('[engine] dispose failed', error instanceof Error ? error.message : error);
+          })
+        );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/providers/EngineProvider.tsx` around lines 62 - 72, Update the
cleanup function returned by the EngineProvider effect so client disposal is
chained after media disposal settles. Preserve the existing error logging for
both operations, including the optional-media case, and ensure
MediaService.dispose() completes before invoking client.dispose() so the broker
cannot access a disposed client.
🧹 Nitpick comments (13)
crates/wasm/src/host.rs (1)

380-387: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Wipe the Rust-side plaintext copy after the Uint8Array copy.

bytes holds decrypted file content. Uint8Array::from copies it into the JS heap, so this function is the terminal owner of the Rust-side buffer. The buffer is then dropped unwiped. The write direction already does this: push_chunk wraps its plaintext in Zeroizing (Line 293).

The same gap exists in download (Line 350). Fix both in one change if you take this.

🔒️ Proposed change
             let bytes = engine
                 .read()
                 .await
                 .read_content_range(node, offset as u64, length as u64)
                 .await
                 .map_err(engine_error)?;
-            Ok(Uint8Array::from(bytes.as_slice()).into())
+            // Terminal owner of this copy: the bytes cross into the JS heap here.
+            let bytes = Zeroizing::new(bytes);
+            Ok(Uint8Array::from(bytes.as_slice()).into())

As per coding guidelines: "Zeroize sensitive material after use at the terminal owner only; callees must not zero caller-owned buffers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/wasm/src/host.rs` around lines 380 - 387, Update the read-content flow
containing read_content_range and the download flow to wrap each decrypted
Rust-side byte buffer in Zeroizing before converting it with Uint8Array::from,
ensuring the plaintext is wiped when the terminal owner releases it. Preserve
the existing JavaScript copy and return behavior, and follow the existing
push_chunk pattern without zeroizing caller-owned buffers.

Source: Coding guidelines

packages/client/src/seams/http.test.ts (1)

70-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The cap bounds the accumulated total, not each chunk. sendCapped checks total + value.byteLength > maxBytes only after reader.read() has already materialized value. One chunk larger than maxBytes is therefore held in memory before rejection, so the true peak bound is maxBytes + one chunk. The test gap and the doc wording both follow from this.

  • packages/client/src/seams/http.test.ts#L70-L91: add a case where a single chunk exceeds the cap, for example producedBody(4096, 4) against a cap of 1000, and assert observed === 4096. Every current oversize case uses 100-byte chunks against a 1000-byte cap, so the total === 0 path never runs.
  • crates/engine/src/seams/http.rs#L144-L150: soften "never materializes an over-cap body" to state that the JS seam aborts the drain once the accumulated body passes the cap, bounded by one chunk of overshoot.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/seams/http.test.ts` around lines 70 - 91, Update
packages/client/src/seams/http.test.ts lines 70-91 by adding a sendCapped case
using a single chunk larger than the limit (for example, producedBody(4096, 4)
with a 1000-byte cap), asserting observed equals 4096 and that the body is
cancelled. Update crates/engine/src/seams/http.rs lines 144-150 to replace the
claim that an over-cap body is never materialized with wording that the JS seam
aborts once accumulated data exceeds the cap, with overshoot bounded by one
chunk.
crates/wasm/src/seams_bridge.rs (1)

621-626: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cover omitted numeric fields in tooLarge results.

required_u64 returns 0 for missing fields or NaN, so capped_count does not panic, but the current tooLarge case always include observed and limit. Add a tooLarge result without those fields so the Rust edge case and CI boundary cover it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/wasm/src/seams_bridge.rs` around lines 621 - 626, Add a `tooLarge`
result case to the relevant bridge test or fixture with both `observed` and
`limit` omitted, then assert it is handled successfully with the existing
zero-value behavior from `capped_count` and `required_u64`. Keep the existing
populated `tooLarge` coverage unchanged.
packages/client/test/browser/media.ts (2)

153-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reset portRequests alongside readerCalls.

cbMediaDispose resets readerCalls but leaves portRequests at its last value. The counters serve the same purpose, so the asymmetry can mislead a later test that calls dispose and then reads portRequests.

♻️ Proposed counter reset
   readerCalls = 0;
+  portRequests = 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/test/browser/media.ts` around lines 153 - 161, Update
cbMediaDispose to reset portRequests alongside readerCalls during teardown,
ensuring both test counters return to their initial state after disposal.

93-96: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Replace the fixed 50 ms sleep with polling on the settled role.

cbMediaEngine resolves after a single 50 ms timeout. The spec then asserts exact 'leader' and 'follower' roles. On a loaded CI machine the Web Locks election may not have settled in 50 ms, and currentRole() returns 'none'. That makes the merge-blocking follower test flaky.

Poll until the role leaves 'none', with a bounded deadline.

♻️ Proposed polling seam
-  // Let the lock election settle so the caller's role assertion is meaningful.
-  return new Promise<string>((resolve) =>
-    setTimeout(() => resolve(client?.currentRole() ?? 'none'), 50)
-  );
+  // The caller asserts an exact role, so wait for the lock election to settle.
+  return (async (): Promise<string> => {
+    for (let attempt = 0; attempt < 100; attempt += 1) {
+      const role = client?.currentRole() ?? 'none';
+      if (role !== 'none') return role;
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    }
+    return 'none';
+  })();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/test/browser/media.ts` around lines 93 - 96, Replace the
fixed timeout in cbMediaEngine with bounded polling of client?.currentRole(),
resolving as soon as the role is no longer 'none'. Add a deadline so polling
cannot hang indefinitely, while preserving the existing role result and fallback
behavior when the deadline is reached.
apps/web/vite.config.ts (1)

21-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The manifest hardcodes the / base.

/${fileName} assumes base is /. If the app is later deployed under a sub-path, every precached URL points at the wrong origin path and the offline app shell breaks. Read the resolved base in configResolved and prefix with it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/vite.config.ts` around lines 21 - 24, Update the bundle URL
construction in the Vite configuration to use the resolved deployment base
instead of hardcoding “/”. Capture the resolved base through the plugin’s
configResolved hook and prefix each fileName in the shell mapping with that
base, preserving the existing source-map filtering and sorting.
packages/client/test/browser/media.spec.ts (1)

182-198: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Give the two tabs distinct fixture bytes to make the isolation assertion meaningful.

Both tabs use localTab, so both synthesize from TAB_SEED. The body assertions at Lines 196 and 198 therefore prove only that each response matches the shared fixture. A cross-tab registry mix-up is caught only by the 404 that a foreign ticket produces, not by the byte comparison. Parameterize the seed per tab so wrong-tab bytes also fail.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/test/browser/media.spec.ts` around lines 182 - 198, Update
the two open-tab test around localTab so each tab is initialized with a distinct
fixture seed. Use the corresponding seed when computing fromA and fromB expected
bodies, preserving the existing ticket sizes and status assertions so cross-tab
responses also fail on mismatched bytes.
packages/client/src/media/service.test.ts (1)

309-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Express the browser API compatibility check with a type assertion.

This test only checks null === null; the real structural check happens at the cast. Use expectTypeOf<ServiceWorkerContainer>().toExtend<ServiceWorkerContainerLike>() and expectTypeOf<MessagePort>().toExtend<MessagePortLike>() so intent is explicit and unsafe casts are not needed.

♻️ Proposed change
-  it('accepts the real browser container and port types structurally', () => {
-    const container: ServiceWorkerContainerLike | null = null as unknown as ServiceWorkerContainer;
-    const port: MessagePortLike | null = null as unknown as MessagePort;
-
-    expect(container).toBeNull();
-    expect(port).toBeNull();
-  });
+  it('accepts the real browser container and port types structurally', () => {
+    expectTypeOf<ServiceWorkerContainer>().toExtend<ServiceWorkerContainerLike>();
+    expectTypeOf<MessagePort>().toExtend<MessagePortLike>();
+  });

Add expectTypeOf to the vitest import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/media/service.test.ts` around lines 309 - 315, Update the
test case “accepts the real browser container and port types structurally” to
use Vitest’s expectTypeOf assertions, verifying ServiceWorkerContainer extends
ServiceWorkerContainerLike and MessagePort extends MessagePortLike. Add
expectTypeOf to the vitest import and remove the null assignments, unsafe casts,
and null expectations.

Source: Path instructions

packages/client/src/sw/install.ts (1)

88-94: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Handle rejection in the activate chain.

The install handler on line 85 ends with .catch(ignore). This chain does not. If caches.keys(), clients.claim(), or readPrecachedUrls rejects, the worker raises an unhandled rejection and the cache mirror stays stale with no signal. Terminate the chain the same way.

♻️ Proposed change
   scope.addEventListener('activate', (event) => {
     event.waitUntil(
       deleteStaleCaches(scope.caches)
         .then(() => scope.clients.claim())
         .then(refresh)
+        .catch(ignore)
     );
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/sw/install.ts` around lines 88 - 94, Update the activate
event chain in the scope.addEventListener('activate', ...) handler to terminate
with the same .catch(ignore) handling used by the install handler. Keep the
existing deleteStaleCaches, clients.claim, and refresh sequence unchanged.
packages/client/src/sw/precache.ts (1)

97-105: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

A hanging network stalls every navigation before the shell fallback.

The navigation path is network-first with no timeout. fetchFn only rejects on a hard failure. On a captive portal or a stalled connection the request stays pending until the browser default timeout, so the cached shell is not served and the UI does not boot. Consider racing the network against a short timer and falling back to the cached document.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/sw/precache.ts` around lines 97 - 105, Update the
navigation branch around fetchFn so the network request races against a short
timeout and rejects when the timer wins, allowing the existing cached
APP_SHELL_DOCUMENT fallback to run; preserve successful network responses and
rethrow the original error when no cached shell exists.
packages/client/src/sw/precache.test.ts (2)

65-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test covers a manifest entry that fails to cache.

FakeCache.addAll in testDoubles.ts always succeeds, so every case here exercises the happy path of cache.addAll. The real Cache.addAll is atomic and rejects the whole call when one entry responds non-OK. That path changes what stays cached on an update and is currently unverified. See the comment on packages/client/src/sw/precache.ts lines 47-53 for the underlying behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/sw/precache.test.ts` around lines 65 - 75, Add a precache
test covering an update manifest where one resource fails during Cache.addAll,
using a test double that rejects atomically like the real Cache API. Assert the
expected retained cache contents after the failed update, alongside the existing
precacheAppShell coverage.

22-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the manifest request itself.

manifestFetch in testDoubles.ts ignores its arguments, so no test proves that readManifest requests /precache-manifest.json resolved against the origin, or that it passes cache: 'no-store'. A change that drops no-store would let a stale manifest pin an old shell, and every test here would still pass. Capture the request in one case.

🧪 Example case
+  it('requests the manifest uncached and same-origin', async () => {
+    const calls: Array<[string, RequestInit | undefined]> = [];
+    const recording = (async (input: string, init?: RequestInit) => {
+      calls.push([input, init]);
+      return new Response('["/index.html"]');
+    }) as unknown as typeof fetch;
+
+    await precacheAppShell(new FakeCacheStorage(), recording, ORIGIN);
+
+    expect(calls[0][0]).toBe(`${ORIGIN}/precache-manifest.json`);
+    expect(calls[0][1]?.cache).toBe('no-store');
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/sw/precache.test.ts` around lines 22 - 49, Update the
precacheAppShell tests to capture the Request passed to the manifest fetch, then
assert that readManifest requests /precache-manifest.json resolved against
ORIGIN with cache set to no-store. Adjust the manifestFetch test double as
needed to expose the captured request while preserving the existing cache
behavior assertions.
packages/client/src/media/range.test.ts (1)

1-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the range arithmetic itself.

The suite covers content-type and hardening headers only. The returned window and content-range values are never asserted, and those drive the bytes the broker streams (packages/client/src/media/broker.ts lines 75-112). Add cases for the suffix form (bytes=-100), the open interval (bytes=100-), a last value past the end (bytes=4000-99999), the malformed and multi-range fall-through to 200, and the unsafe-integer digit run that must fail closed.

🧪 Example additional cases
+describe('resolveMediaRequest window', () => {
+  it('resolves a suffix range against the tail', () => {
+    const head = resolveMediaRequest('bytes=-100', SIZE, 'video/mp4');
+    expect(head).toMatchObject({ status: 206, window: { offset: SIZE - 100, length: 100 } });
+    expect(headerMap(head).get('content-range')).toBe(`bytes ${SIZE - 100}-${SIZE - 1}/${SIZE}`);
+  });
+
+  it('clamps a last-byte position past the end', () => {
+    const head = resolveMediaRequest('bytes=4000-99999', SIZE, 'video/mp4');
+    expect(head).toMatchObject({ status: 206, window: { offset: 4000, length: SIZE - 4000 } });
+  });
+
+  it('answers a malformed or multi-range spec with the whole file', () => {
+    expect(resolveMediaRequest('bytes=abc', SIZE, 'video/mp4').status).toBe(200);
+    expect(resolveMediaRequest('bytes=0-9,20-29', SIZE, 'video/mp4').status).toBe(200);
+  });
+
+  it('fails closed on a digit run past the safe-integer range', () => {
+    expect(resolveMediaRequest('bytes=99999999999999999999-', SIZE, 'video/mp4').status).toBe(416);
+  });
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/media/range.test.ts` around lines 1 - 47, Expand the
resolveMediaRequest tests to assert both returned window values and
content-range headers, covering suffix ranges (bytes=-100), open-ended ranges
(bytes=100-), end values beyond SIZE (bytes=4000-99999), malformed and
multi-range inputs falling back to 200, and unsafe-integer digit runs failing
closed. Reuse headerMap and the existing SIZE fixture while verifying the exact
safe byte window and response behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/providers/EngineProvider.tsx`:
- Around line 96-99: Add runtime tests in EngineProvider.test.tsx covering
useMediaService(): assert it returns the provider’s media service when available
and null when no Service Worker exists. Exercise the media context field through
the provider setup while preserving the existing lifecycle and teardown
coverage.

In `@apps/web/vite.config.ts`:
- Around line 50-53: Update the Rollup output configuration in vite.config.ts to
remove the unsupported output.codeSplitting option and set output.format to
'iife' with output.inlineDynamicImports enabled. Keep SW_FILE as the entry
filename so the generated classic service worker remains /sw.js.

In `@crates/engine/tests/write_plane.rs`:
- Around line 843-848: Extend the range-read assertions in the write-plane test
around read_content_range to add a case whose offset is strictly greater than
whole.len(). Verify that this past-EOF request returns an empty result without
error, covering the manifest.size.saturating_sub(offset) path while preserving
the existing offset-equal-to-length case.

In `@packages/client/src/broadcast.ts`:
- Around line 78-79: Update the response documentation comment near the snapshot
and plaintext read descriptions to replace the incomplete phrase with “a
plaintext read carries a Blob,” preserving the surrounding wording.

In `@packages/client/src/media/broker.test.ts`:
- Around line 1-9: Add a named CI job or PR gate that explicitly runs the client
unit-test command for the new media and service-worker suites, using the
`@cipherbox/client` test filter or an equivalent dedicated client-unit command.
Keep the existing browser/Playwright coverage and ensure this gate is included
in the required CI workflow.

In `@packages/client/src/media/service.ts`:
- Around line 128-139: Scope MEDIA_PORT_REQUEST broadcasts to the client that
actually needs a port instead of triggering rebroker() in every tab. Extend
MediaPortRequest with the target client identifier, pass the known clientId
through acquirePort, and post the request only to that client. Keep
onMessage/rebroker unchanged so unrelated tabs retain their active brokers and
streams.

In `@packages/client/src/seams/types.ts`:
- Around line 92-94: Format the CappedHttpResult type declaration with the
repository’s Prettier configuration, specifically normalizing the multiline
union formatting around the response and tooLarge variants. Do not change the
type’s semantics.

In `@packages/client/src/sw/install.test.ts`:
- Around line 42-47: Apply the repository’s Prettier formatting to the union
type annotation in the ServiceWorker event dispatch method, preserving the
existing dispatch behavior and type cast.

In `@packages/client/src/sw/install.ts`:
- Around line 74-109: Track the in-flight cache reload initiated by refresh()
and reassign that promise whenever refresh runs during startup, install, or
activate. In the fetch listener, preserve navigation handling, but for
same-origin GET requests whose app-shell claim is unknown, await the learning
promise before evaluating appShellClaims and responding; add or reuse
sameOriginGet to limit this wait to eligible requests. Export the helper from
precache.ts if it is implemented there.

In `@packages/client/src/sw/pipe.ts`:
- Around line 161-173: Track each pending pull timer by request ID in the
body/pullWindow flow, and clear that timer during body.cancel before deleting
the sink or posting cb:media:close, so cancellation settles without invoking
discardPort or closing the port. In packages/client/src/sw/pipe.ts lines 161-173
update the implementation; in packages/client/src/sw/pipe.test.ts lines 176-185
add coverage that cancels a body using stalledPort(), advances beyond
pullTimeoutMs, and verifies the port remains open.
- Around line 276-285: Update sealed to copy header pairs into merged
defensively, ignoring any pair whose name or value causes Headers validation to
throw, while preserving valid headers and the forced cache-control: no-store
setting. Keep the existing response status/body behavior and do not add sniffing
or CSP headers here.

In `@packages/client/src/sw/precache.ts`:
- Around line 47-53: Update precacheAppShell in
packages/client/src/sw/precache.ts#L47-L53 to cache manifest URLs individually,
tolerate a rejected addAll for one URL, and build the prune set only from URLs
that cached successfully. In packages/client/src/sw/precache.test.ts#L65-L75,
add coverage using a FakeCache.addAll that rejects for a selected URL, asserting
other manifest entries cache and unlisted entries are pruned.

In `@packages/client/test/browser/media.spec.ts`:
- Around line 213-216: Guard the CDP-based service-worker cleanup in the test
containing the `context.newCDPSession` call so it runs only for Chromium; skip
the test or bypass this block for Firefox and WebKit projects while preserving
the existing Chromium behavior.

---

Outside diff comments:
In `@apps/web/src/providers/EngineProvider.tsx`:
- Around line 62-72: Update the cleanup function returned by the EngineProvider
effect so client disposal is chained after media disposal settles. Preserve the
existing error logging for both operations, including the optional-media case,
and ensure MediaService.dispose() completes before invoking client.dispose() so
the broker cannot access a disposed client.

---

Nitpick comments:
In `@apps/web/vite.config.ts`:
- Around line 21-24: Update the bundle URL construction in the Vite
configuration to use the resolved deployment base instead of hardcoding “/”.
Capture the resolved base through the plugin’s configResolved hook and prefix
each fileName in the shell mapping with that base, preserving the existing
source-map filtering and sorting.

In `@crates/wasm/src/host.rs`:
- Around line 380-387: Update the read-content flow containing
read_content_range and the download flow to wrap each decrypted Rust-side byte
buffer in Zeroizing before converting it with Uint8Array::from, ensuring the
plaintext is wiped when the terminal owner releases it. Preserve the existing
JavaScript copy and return behavior, and follow the existing push_chunk pattern
without zeroizing caller-owned buffers.

In `@crates/wasm/src/seams_bridge.rs`:
- Around line 621-626: Add a `tooLarge` result case to the relevant bridge test
or fixture with both `observed` and `limit` omitted, then assert it is handled
successfully with the existing zero-value behavior from `capped_count` and
`required_u64`. Keep the existing populated `tooLarge` coverage unchanged.

In `@packages/client/src/media/range.test.ts`:
- Around line 1-47: Expand the resolveMediaRequest tests to assert both returned
window values and content-range headers, covering suffix ranges (bytes=-100),
open-ended ranges (bytes=100-), end values beyond SIZE (bytes=4000-99999),
malformed and multi-range inputs falling back to 200, and unsafe-integer digit
runs failing closed. Reuse headerMap and the existing SIZE fixture while
verifying the exact safe byte window and response behavior.

In `@packages/client/src/media/service.test.ts`:
- Around line 309-315: Update the test case “accepts the real browser container
and port types structurally” to use Vitest’s expectTypeOf assertions, verifying
ServiceWorkerContainer extends ServiceWorkerContainerLike and MessagePort
extends MessagePortLike. Add expectTypeOf to the vitest import and remove the
null assignments, unsafe casts, and null expectations.

In `@packages/client/src/seams/http.test.ts`:
- Around line 70-91: Update packages/client/src/seams/http.test.ts lines 70-91
by adding a sendCapped case using a single chunk larger than the limit (for
example, producedBody(4096, 4) with a 1000-byte cap), asserting observed equals
4096 and that the body is cancelled. Update crates/engine/src/seams/http.rs
lines 144-150 to replace the claim that an over-cap body is never materialized
with wording that the JS seam aborts once accumulated data exceeds the cap, with
overshoot bounded by one chunk.

In `@packages/client/src/sw/install.ts`:
- Around line 88-94: Update the activate event chain in the
scope.addEventListener('activate', ...) handler to terminate with the same
.catch(ignore) handling used by the install handler. Keep the existing
deleteStaleCaches, clients.claim, and refresh sequence unchanged.

In `@packages/client/src/sw/precache.test.ts`:
- Around line 65-75: Add a precache test covering an update manifest where one
resource fails during Cache.addAll, using a test double that rejects atomically
like the real Cache API. Assert the expected retained cache contents after the
failed update, alongside the existing precacheAppShell coverage.
- Around line 22-49: Update the precacheAppShell tests to capture the Request
passed to the manifest fetch, then assert that readManifest requests
/precache-manifest.json resolved against ORIGIN with cache set to no-store.
Adjust the manifestFetch test double as needed to expose the captured request
while preserving the existing cache behavior assertions.

In `@packages/client/src/sw/precache.ts`:
- Around line 97-105: Update the navigation branch around fetchFn so the network
request races against a short timeout and rejects when the timer wins, allowing
the existing cached APP_SHELL_DOCUMENT fallback to run; preserve successful
network responses and rethrow the original error when no cached shell exists.

In `@packages/client/test/browser/media.spec.ts`:
- Around line 182-198: Update the two open-tab test around localTab so each tab
is initialized with a distinct fixture seed. Use the corresponding seed when
computing fromA and fromB expected bodies, preserving the existing ticket sizes
and status assertions so cross-tab responses also fail on mismatched bytes.

In `@packages/client/test/browser/media.ts`:
- Around line 153-161: Update cbMediaDispose to reset portRequests alongside
readerCalls during teardown, ensuring both test counters return to their initial
state after disposal.
- Around line 93-96: Replace the fixed timeout in cbMediaEngine with bounded
polling of client?.currentRole(), resolving as soon as the role is no longer
'none'. Add a deadline so polling cannot hang indefinitely, while preserving the
existing role result and fallback behavior when the deadline is reached.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9070e041-d803-4a13-8b53-ba328e6f76ba

📥 Commits

Reviewing files that changed from the base of the PR and between afb3887 and f9f370b.

📒 Files selected for processing (62)
  • apps/web/src/engine/createMediaService.test.ts
  • apps/web/src/engine/createMediaService.ts
  • apps/web/src/providers/EngineProvider.tsx
  • apps/web/src/sw.ts
  • apps/web/vite.config.ts
  • crates/engine/src/content/mod.rs
  • crates/engine/src/facade.rs
  • crates/engine/src/seams/http.rs
  • crates/engine/src/seams/mod.rs
  • crates/engine/tests/content_wipe.rs
  • crates/engine/tests/write_plane.rs
  • crates/wasm/src/host.rs
  • crates/wasm/src/seams_bridge.rs
  • packages/client/package.json
  • packages/client/src/broadcast.ts
  • packages/client/src/broadcastTransport.test.ts
  • packages/client/src/broadcastTransport.ts
  • packages/client/src/correlatedTransport.ts
  • packages/client/src/engineClient.ts
  • packages/client/src/facade.test.ts
  • packages/client/src/facade.ts
  • packages/client/src/index.ts
  • packages/client/src/leaderRelay.ts
  • packages/client/src/media/broker.test.ts
  • packages/client/src/media/broker.ts
  • packages/client/src/media/protocol.ts
  • packages/client/src/media/range.test.ts
  • packages/client/src/media/range.ts
  • packages/client/src/media/registry.test.ts
  • packages/client/src/media/registry.ts
  • packages/client/src/media/service.test.ts
  • packages/client/src/media/service.ts
  • packages/client/src/seams/http.test.ts
  • packages/client/src/seams/http.ts
  • packages/client/src/seams/index.ts
  • packages/client/src/seams/types.ts
  • packages/client/src/sw/install.test.ts
  • packages/client/src/sw/install.ts
  • packages/client/src/sw/pipe.test.ts
  • packages/client/src/sw/pipe.ts
  • packages/client/src/sw/precache.test.ts
  • packages/client/src/sw/precache.ts
  • packages/client/src/sw/serviceWorker.ts
  • packages/client/src/sw/testDoubles.ts
  • packages/client/src/testkit.ts
  • packages/client/src/transport.ts
  • packages/client/src/worker/engineHost.ts
  • packages/client/src/worker/engineWasm.ts
  • packages/client/src/worker/protocol.ts
  • packages/client/src/worker/serve.test.ts
  • packages/client/src/worker/serve.ts
  • packages/client/test/browser/conformance.worker.ts
  • packages/client/test/browser/fakeEngine.worker.ts
  • packages/client/test/browser/index.html
  • packages/client/test/browser/journalEngine.worker.ts
  • packages/client/test/browser/media.spec.ts
  • packages/client/test/browser/media.ts
  • packages/client/test/browser/mediaEngine.worker.ts
  • packages/client/test/browser/mediaFixture.ts
  • packages/client/test/browser/sw.ts
  • packages/client/test/browser/vite.config.ts
  • packages/client/tsconfig.build.json

Comment thread apps/web/src/providers/EngineProvider.tsx
Comment thread apps/web/vite.config.ts
Comment thread crates/engine/tests/write_plane.rs Outdated
Comment thread packages/client/src/broadcast.ts Outdated
Comment thread packages/client/src/media/broker.test.ts
Comment thread packages/client/src/sw/install.ts
Comment thread packages/client/src/sw/pipe.ts
Comment thread packages/client/src/sw/pipe.ts
Comment thread packages/client/src/sw/precache.ts
Comment thread packages/client/test/browser/media.spec.ts
@FSM1
FSM1 marked this pull request as draft August 2, 2026 13:07
FSM1 added a commit that referenced this pull request Aug 2, 2026
Addresses the CodeRabbit review on #951.

- Aim `cb:media:needPort` at the tab that lacks a port. A broadcast made
  every other tab re-broker, and a superseded broker drops the cursors of
  bodies still streaming on it.
- Disarm a pull deadline when its body is cancelled, so the timer cannot
  later discard a working port under the other bodies on it.
- Build the sealed response headers pair by pair; a name or value the
  `Headers` grammar rejects must not fail the whole response.
- Cache manifest entries one at a time. `addAll` is atomic, so a single
  unreachable asset dropped the entire shell. Pruning still follows the
  manifest, so a failed refresh does not evict a good entry.
- Await the shell re-learn on a restarted worker before claiming a
  same-origin GET, and terminate the activate chain like the install one.
- Emit the Service Worker as `iife`: with `es`, a dynamic import anywhere
  in its graph emits `import.meta`, which a classic worker rejects.
- Cover a strictly past-EOF ranged read, and `useMediaService`.
FSM1 added a commit that referenced this pull request Aug 2, 2026
Follows the body-only findings on the CodeRabbit review of #951.

- Wrap the plaintext `download` and `downloadRange` return in `Zeroizing`.
  Both are the terminal owner of the Rust-side buffer once the bytes are
  copied into the JS heap, matching what `push_chunk` already does for the
  write direction.
- Poll the lock election in the browser harness instead of sleeping a fixed
  50 ms before asserting an exact role, and reset `portRequests` alongside
  `readerCalls` on dispose.
- Assert the precache manifest is requested same-origin with `no-store`; a
  cached manifest would pin the shell to a superseded deploy.

FSM1 commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit body-only findings

The outside-diff comment and the 13 nitpicks, none of which carry threads. All verified against the branch; applied in 7b2a550 / c2bb578 unless noted.


Applied

crates/wasm/src/host.rs 380-387 — wipe the Rust-side plaintext copy.

Correct, and it is repo security rule 7 rather than a nitpick: download and downloadRange are the terminal owner of the decrypted buffer once Uint8Array::from copies it into the JS heap, and both dropped it unwiped. The write direction already establishes the pattern at push_chunk (Zeroizing, line 293). Both read paths now match. cargo clippy -p cipherbox-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings is clean.

packages/client/test/browser/media.ts 93-96 — replace the fixed 50 ms sleep with polling.

Real flake, and it sits in the merge-blocking browser suite. cbMediaEngine resolved after one 50 ms timeout while the spec asserts exact 'leader'/'follower'; a loaded runner that has not settled the Web Locks election yields 'none' and fails. Now polls until the role leaves 'none', bounded at 100 attempts. The function had to become async for it.

packages/client/test/browser/media.ts 153-161 — reset portRequests on dispose.

Applied. The two counters serve the same purpose and only one was reset.

packages/client/src/sw/precache.test.ts 22-49 — assert the manifest request itself.

Applied, with the reason sharpened: nothing proved readManifest sends cache: 'no-store', and dropping it would let a cached manifest pin the shell to a superseded deploy while every existing test still passed.

packages/client/src/sw/install.ts 88-94 — terminate the activate chain.
packages/client/src/sw/precache.test.ts 65-75 — cover a failing addAll.

Both folded into the threads on install.ts 74-109 and precache.ts 47-53 — they touch the same lines, so splitting them would have meant editing twice. See those threads.


Declined

apps/web/src/providers/EngineProvider.tsx 62-72 (outside diff) — order media disposal before client disposal.

The ordering observation is right and I tried it; the fix costs more than it buys, so it is out.

Chaining client.dispose() behind media.dispose() makes engine teardown asynchronous, and two existing tests caught it immediately — disposes the client and its snapshot store when the provider unmounts and leaves exactly one live client after a StrictMode double-mount both failed, the latter reporting 2 live clients where it demands 1.

That is not merely a test-shape problem. Your own note flags it: MediaService.dispose() opens with await this.startup, and startup awaits container.register(...) then container.ready. So the chain gates releasing the engine — and the Web Lock behind it — on Service Worker registration settling, which has no deadline and never rejects. A StrictMode double-mount would leave the throwaway client contending for the lock until registration completes.

Against that, the failure being fixed is: a read in flight during teardown resolves against a disposed client and the media element errors. Teardown means the tab is unmounting or the engine is being rebuilt, so that stream is going away regardless. Trading an unbounded lock-release delay for a benign error on a dying stream is the wrong direction.

Left as-is, and disposes this tab's media service with the provider now pins that the media service is disposed at all.

apps/web/vite.config.ts 21-24 — the manifest hardcodes the / base.

Accurate but not actionable yet, and it is not the only place: precache.ts also hardcodes /precache-manifest.json and /index.html, and the worker registers at scope: '/'. A sub-path deploy needs all four moved together. blueprint/web-client.md specifies a root-scoped worker, so a sub-path deploy is not a supported target — wiring configResolved here alone would give the appearance of sub-path support while the shell still broke. If it becomes a target it should land as one change with a test, not as three-quarters of one.

packages/client/src/sw/precache.ts 97-105 — race the navigation fetch against a timeout.

Correct that a stalled network delays the shell, but the proposed cure is worse than the disease. Any fixed timeout is wrong for someone: too short and a slow-but-working connection gets served a stale cached shell it never asked for, on every navigation. The browser already applies its own network timeout, and a captive portal typically fails fast rather than hanging. Deferring until there is a real measurement to size the timer against.

packages/client/src/media/service.test.ts 309-315 — use expectTypeOf.

Fair that the test only checks null === null and the real work happens at the cast. Not taking it: expectTypeOf is a compile-time assertion that tsc --noEmit already performs on that same cast in the same file. The suggestion swaps one tautology for another rather than adding coverage. The structural compatibility is genuinely enforced by the typecheck gate.

packages/client/src/media/range.test.ts 1-47 — cover the range arithmetic.

The gap is real and this is the most valuable of the nitpicks — window and content-range drive the bytes the broker streams and neither is asserted here. It is also the largest, and the cases named (suffix form, open interval, last past EOF, multi-range fall-through, unsafe-integer fail-closed) deserve to be written deliberately rather than appended to a review sweep. Filed as follow-up work rather than done thinly here; the browser suite currently covers the 200/206/416 outcomes end to end, so the behaviour is not unverified, only under-unit-tested.

packages/client/src/seams/http.test.ts 70-91 — the cap bounds the accumulated total, not each chunk.

The analysis is right: sendCapped checks the running total only after reader.read() has materialized value, so peak memory is maxBytes plus one chunk, and every existing oversize case uses 100-byte chunks against a 1000-byte cap so the total === 0 path never runs. Not applied in this pass because the accompanying half — softening the "never materializes an over-cap body" wording in crates/engine/src/seams/http.rs — is a claim about the seam contract that belongs with the engine-side seam review, not a test-only edit. Carrying both together as follow-up.

crates/wasm/src/seams_bridge.rs 621-626 — a tooLarge result with the numeric fields omitted.

required_u64 already returns 0 for a missing field, which the comment concedes, so there is no crash or misbehaviour to pin — the test would assert that a defaulting helper defaults. Declined as coverage without a corresponding failure mode.

packages/client/test/browser/media.spec.ts 182-198 — distinct fixture bytes per tab.

The point stands that both tabs synthesize from TAB_SEED, so the byte assertions cannot catch a cross-tab registry mix-up — only the 404 from a foreign ticket can. mediaFixture.ts already exports LEADER_SEED alongside TAB_SEED, so the seam exists. Not doing it here: localTab hardcodes the seed for every caller, so parameterizing it touches the shared helper and the specs that depend on it. Grouping with the range.test.ts work above.


Follow-up items above are unfiled — say the word and I will open them against #655 with the usual dependency edges, or fold them into this PR if you would rather they not ship separately.


Generated by Claude Code

FSM1 commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Deferred follow-ups now filed

The body-only comment above left four findings as follow-up work pending a decision on whether to file them. Filed against #655 with the usual dependency edges, and the reverse edge added on #641:

#963 — web-client: unit-cover the media range arithmetic and give each tab distinct fixture bytes.

Carries the range.test.ts and media.spec.ts items, which were already grouped above. Re-verified on the branch: range.test.ts asserts only content-type and the hardening headers and never reads head.window or a content-range value, so every branch of resolveMediaRequest — suffix clamp, open interval, last past EOF, the two 416 paths, the multi-range fall-through, and the unsafe-integer fail-closed — is unasserted. media.ts line 71 hardcodes TAB_SEED in the downloadRange stub, so both tabs in the two-tab spec synthesize identical bytes and only the foreign-ticket 404 can catch a registry mix-up.

#964 — seams: the capped fetch overshoots the cap by one chunk and the Http seam doc overclaims.

Carries the http.test.ts item plus the engine-side half that was held back as belonging with the seam contract rather than a test-only edit.

One correction to the earlier reasoning while verifying it: the overshoot is not specific to the JS seam. crates/desktop-seams/src/http.rs checks body.len() + chunk.len() > max_bytes only after response.chunk().await has materialized the chunk — structurally identical to sendCapped. So the send_capped doc is inaccurate on both bullets, and the distinction it draws between a desktop "true peak-memory bound" and a WASM seam that "never materializes an over-cap body" describes a difference that does not exist. That widens the doc fix from softening one sentence to stating a single bound for both arms, which is why it is worth its own issue rather than a wording tweak here.

Neither is a trust or memory-safety escape — both arms still abort the drain, and transport chunk sizes sit well below the 4 MiB content cap — so both stay out of this PR.

No code change in this pass; the PR is unchanged and remains a draft.


Generated by Claude Code

FSM1 added 3 commits August 2, 2026 22:23
The Service Worker is demoted to a dumb pipe: a media element requests an
opaque /stream/ ticket URL, the worker forwards the Range over a
MessageChannel port brokered at registration, and plaintext streams back
through the port a window at a time. It holds no keys, no crypto, and no
state beyond its ports, so a killed worker or a dead port re-brokers and
re-buffers. Follower tabs route media through the leader. The same worker
precaches the app shell so the UI boots offline, and never caches vault data.

Under it, a ranged plaintext read: open_content_range fetches and unseals
only the leaves a window covers, failing closed when a leaf's unsealed
length disagrees with the manifest, and read_content_range shares the
resolve and adoption gate with the whole-file read.

The JS HttpSeam now enforces a true streaming peak-memory bound, mirroring
desktop: a Content-Length pre-check, then a reader drain that cancels the
moment the running total would pass the cap, so a gateway that omits or
lies about Content-Length can no longer OOM the tab.

Closes #641
Addresses the CodeRabbit review on #951.

- Aim `cb:media:needPort` at the tab that lacks a port. A broadcast made
  every other tab re-broker, and a superseded broker drops the cursors of
  bodies still streaming on it.
- Disarm a pull deadline when its body is cancelled, so the timer cannot
  later discard a working port under the other bodies on it.
- Build the sealed response headers pair by pair; a name or value the
  `Headers` grammar rejects must not fail the whole response.
- Cache manifest entries one at a time. `addAll` is atomic, so a single
  unreachable asset dropped the entire shell. Pruning still follows the
  manifest, so a failed refresh does not evict a good entry.
- Await the shell re-learn on a restarted worker before claiming a
  same-origin GET, and terminate the activate chain like the install one.
- Emit the Service Worker as `iife`: with `es`, a dynamic import anywhere
  in its graph emits `import.meta`, which a classic worker rejects.
- Cover a strictly past-EOF ranged read, and `useMediaService`.
Follows the body-only findings on the CodeRabbit review of #951.

- Wrap the plaintext `download` and `downloadRange` return in `Zeroizing`.
  Both are the terminal owner of the Rust-side buffer once the bytes are
  copied into the JS heap, matching what `push_chunk` already does for the
  write direction.
- Poll the lock election in the browser harness instead of sleeping a fixed
  50 ms before asserting an exact role, and reset `portRequests` alongside
  `readerCalls` on dispose.
- Assert the precache manifest is requested same-origin with `no-store`; a
  cached manifest would pin the shell to a superseded deploy.
@FSM1
FSM1 force-pushed the feat/641-sw-byte-pipe-precache branch from c2bb578 to f59273a Compare August 2, 2026 20:27
The browser harnesses stood a fixed 50 ms sleep in for the election, so a
loaded runner reported EngineClient's pre-election 'follower' default as if
it were the outcome. Both roles now wait on an observable: leader on
currentRole(), which flips only after promotion spawns the worker; follower
on this tab's own queued request sitting behind another tab's held lock,
matched by Web Locks client id so a tab that never requested the lock fails
the wait instead of passing on the default.
@FSM1
FSM1 force-pushed the feat/641-sw-byte-pipe-precache branch from f59273a to d7fa222 Compare August 2, 2026 20:29
FSM1 added 2 commits August 2, 2026 23:15
`head_version` moved the head `Version` out of the version list with
`into_iter().next()`. That is a bitwise `ptr::read`, and `IntoIter::drop`
drops only elements `1..n`, so slot 0's `SecretBytes` — 32 bytes of
content key — reached the allocator without its zeroizing `Drop`. Clone
the head instead so the whole `Vec` drops in place.

`open_content_range` assembled into a bare `Vec`, which the two per-leaf
trust rejects abandon while it already holds verified plaintext. It is
now `Zeroizing`, handed to the caller with `mem::take` so the caller
stays the terminal owner.

The zeroize watchdog could pass vacuously: it only scanned freed blocks
of exactly one size, so a window in which none were freed tested
nothing. It now counts what it inspected and asserts that count is
non-zero, scans every block large enough to hold a marker, and covers
the abandoned-assembly-buffer case — that test fails without the
`Zeroizing` above.

Also collapses `read_content` onto `read_content_range`: the range
clamps to the manifest size, so the whole-file read is the unbounded
window. `read_content_inner` and `open_content` are gone, and the
duplicated snapshot projection with them; the `OpProgress` surface of
both entry points is unchanged. Shared gateway/serve/block-store
fixtures move to the testkit, their one home.
`portFor` short-circuited with a newest-wins fallback before `acquirePort`
could target the owning client, so two open tabs plus a routine worker
restart resolved tab A's ticket against tab B's registry — an unknown
ticket, a hard 404 mid-playback, no retry. A non-anonymous client with no
port of its own now gets `null` so the owner is asked; the newest-port
fallback survives only for a request carrying no client identity. Waiters
resolve on an adoption by their own client, not on any adoption.

`sealed` forced only `cache-control` and re-emitted whatever
`content-type` and CSP the port supplied, on a boundary the file itself
calls untrusted. It now clamps the type through `safeMimeType` and forces
`nosniff` and the sandbox CSP on every response carrying a body; the
range resolver's copies stay as belt-and-braces.

Drops `ok` from the head wire message. Every producer set it as exactly
`status === 200 || status === 206` and nothing cross-checked the two, so
`ok: true, status: 404` was representable; the status alone now decides.
Four hand-rolled `EngineHostLike` doubles re-stubbed the same eleven
methods, so adding one member to the interface meant five edits. A
`StubEngineHost` base whose every method rejects now carries them; the
media worker's double turned out to be missing `siweChallenge`
entirely, which nothing caught because no tsconfig covers
`test/browser`. Three near-identical `MessagePortLike` doubles collapse
into one `FakePort`, and `errorMessage` moves beside its two callers
instead of being spelled a third time.

Drops two tests that cannot fail: one asserted `toBeNull()` on values
cast from `null`, the other counted `put` calls on a `CacheLike` that
has no `put`. The prototype spy on `Uint8Array.prototype.set` goes too —
it pinned implementation shape and leaked on failure; the body
assertion carries the claim.

`serve.ts` no longer re-discriminates the union its own switch just
discriminated, `broker.ts` spells the head post and the stale-cursor
guard once each, and `precache.ts` reuses `sameOriginGet` rather than
re-deriving it.

Comment pass: four absence-justifying comments removed, four rationales
restated at multiple sites collapsed to one home each, and the
`sendCapped` doc no longer calls an inclusive cap exclusive.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/client/src/seams/http.test.ts (1)

82-91: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a case for a malformed Content-Length.

The seam converts the header with Number(contentLength) and gates on Number.isFinite(declared). A non-numeric header such as abc produces NaN, and a negative header produces a finite value below the cap. Both fall through to the streaming path. The suite covers absent and lying-small headers but not these two. One test pins the fall-through as intended behavior rather than an accident.

As per path instructions for **/*.test.ts: "Focus on test coverage, edge cases, and test quality."

🧪 Proposed additional case
+  it('ignores a non-numeric Content-Length and enforces the cap while streaming', async () => {
+    const body = producedBody(100, 100);
+    stubFetch(new Response(body.stream, { status: 200, headers: { 'content-length': 'abc' } }));
+
+    const result = await new FetchHttp().sendCapped(GET, 1000);
+
+    expect(result).toEqual({ kind: 'tooLarge', observed: 1100, limit: 1000 });
+    expect(body.cancelled()).toBe(true);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/seams/http.test.ts` around lines 82 - 91, Add a test near
the existing Content-Length cases for malformed declarations, covering both a
non-numeric value such as “abc” and a negative value. Verify each remains on the
streaming path and preserves the intended result, rather than being rejected by
the declared-size cap check.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/content/mod.rs`:
- Around line 228-235: Prevent plaintext loss during Vec reallocation in the
content assembly path around plaintext and extend_from_slice: either validate
length against an explicit caller-respected cap and reserve the full size once,
or implement explicit growth that zeroizes the existing buffer before replacing
it. Preserve the existing allocation-budget defense and trust checks, and add a
content-wipe test with a requested range exceeding MAX_RESOLVED_RECORD_BYTES to
exercise growth.

In `@crates/engine/tests/content_wipe.rs`:
- Around line 1-20: Add a required CI job in the existing .github/workflows
configuration, named clearly for the content-wipe suite (for example,
content_wipe or cargo-wipe), that invokes the cipherbox-engine content_wipe test
binary via cargo test -p cipherbox-engine with the allocator-backed targets and
configuration it requires. Ensure the job is part of the normal required
workflow path and explicitly runs this suite rather than relying only on
unrelated engine tests.

In `@packages/client/src/sw/install.test.ts`:
- Around line 106-198: Add a named CI job or Vitest entry for the
installServiceWorker suite in the existing client-package test workflow. Ensure
the new gate explicitly runs the install.test suite alongside the remaining
packages/client tests and is visible as a distinct named check in CI.

---

Nitpick comments:
In `@packages/client/src/seams/http.test.ts`:
- Around line 82-91: Add a test near the existing Content-Length cases for
malformed declarations, covering both a non-numeric value such as “abc” and a
negative value. Verify each remains on the streaming path and preserves the
intended result, rather than being rejected by the declared-size cap check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 403b1b8a-6935-4cd8-a2b0-ec9e0d611336

📥 Commits

Reviewing files that changed from the base of the PR and between f9f370b and c18a254.

📒 Files selected for processing (68)
  • apps/web/src/engine/createMediaService.test.ts
  • apps/web/src/engine/createMediaService.ts
  • apps/web/src/providers/EngineProvider.test.tsx
  • apps/web/src/providers/EngineProvider.tsx
  • apps/web/src/sw.ts
  • apps/web/vite.config.ts
  • crates/engine/src/content/mod.rs
  • crates/engine/src/facade.rs
  • crates/engine/src/seams/http.rs
  • crates/engine/src/seams/mod.rs
  • crates/engine/src/testkit/content.rs
  • crates/engine/src/testkit/mod.rs
  • crates/engine/tests/content_wipe.rs
  • crates/engine/tests/write_plane.rs
  • crates/wasm/src/host.rs
  • crates/wasm/src/seams_bridge.rs
  • packages/client/package.json
  • packages/client/src/broadcast.ts
  • packages/client/src/broadcastTransport.test.ts
  • packages/client/src/broadcastTransport.ts
  • packages/client/src/correlatedTransport.ts
  • packages/client/src/engineClient.ts
  • packages/client/src/errorMessage.ts
  • packages/client/src/facade.test.ts
  • packages/client/src/facade.ts
  • packages/client/src/index.ts
  • packages/client/src/leaderRelay.ts
  • packages/client/src/media/broker.test.ts
  • packages/client/src/media/broker.ts
  • packages/client/src/media/protocol.ts
  • packages/client/src/media/range.test.ts
  • packages/client/src/media/range.ts
  • packages/client/src/media/registry.test.ts
  • packages/client/src/media/registry.ts
  • packages/client/src/media/service.test.ts
  • packages/client/src/media/service.ts
  • packages/client/src/seams/http.test.ts
  • packages/client/src/seams/http.ts
  • packages/client/src/seams/index.ts
  • packages/client/src/seams/types.ts
  • packages/client/src/sw/install.test.ts
  • packages/client/src/sw/install.ts
  • packages/client/src/sw/pipe.test.ts
  • packages/client/src/sw/pipe.ts
  • packages/client/src/sw/precache.test.ts
  • packages/client/src/sw/precache.ts
  • packages/client/src/sw/serviceWorker.ts
  • packages/client/src/sw/testDoubles.ts
  • packages/client/src/testkit.ts
  • packages/client/src/transport.ts
  • packages/client/src/worker/engineHost.ts
  • packages/client/src/worker/engineWasm.ts
  • packages/client/src/worker/protocol.ts
  • packages/client/src/worker/serve.test.ts
  • packages/client/src/worker/serve.ts
  • packages/client/test/browser/conformance.worker.ts
  • packages/client/test/browser/election.ts
  • packages/client/test/browser/fakeEngine.worker.ts
  • packages/client/test/browser/index.html
  • packages/client/test/browser/journalEngine.worker.ts
  • packages/client/test/browser/leadership.ts
  • packages/client/test/browser/media.spec.ts
  • packages/client/test/browser/media.ts
  • packages/client/test/browser/mediaEngine.worker.ts
  • packages/client/test/browser/mediaFixture.ts
  • packages/client/test/browser/sw.ts
  • packages/client/test/browser/vite.config.ts
  • packages/client/tsconfig.build.json
🚧 Files skipped from review as they are similar to previous changes (50)
  • packages/client/src/broadcastTransport.test.ts
  • packages/client/src/facade.ts
  • apps/web/src/engine/createMediaService.test.ts
  • packages/client/test/browser/sw.ts
  • packages/client/src/correlatedTransport.ts
  • packages/client/tsconfig.build.json
  • packages/client/src/media/registry.test.ts
  • packages/client/src/sw/serviceWorker.ts
  • packages/client/src/seams/http.ts
  • crates/wasm/src/seams_bridge.rs
  • crates/engine/src/seams/mod.rs
  • packages/client/src/engineClient.ts
  • packages/client/src/worker/engineHost.ts
  • apps/web/src/sw.ts
  • apps/web/src/providers/EngineProvider.tsx
  • packages/client/src/broadcastTransport.ts
  • packages/client/test/browser/mediaEngine.worker.ts
  • packages/client/src/worker/engineWasm.ts
  • packages/client/src/facade.test.ts
  • packages/client/test/browser/media.spec.ts
  • packages/client/src/sw/precache.test.ts
  • packages/client/src/leaderRelay.ts
  • crates/engine/tests/write_plane.rs
  • packages/client/src/seams/index.ts
  • packages/client/src/media/service.test.ts
  • packages/client/test/browser/mediaFixture.ts
  • packages/client/test/browser/index.html
  • packages/client/test/browser/conformance.worker.ts
  • packages/client/package.json
  • packages/client/src/worker/serve.test.ts
  • apps/web/vite.config.ts
  • packages/client/src/sw/pipe.test.ts
  • packages/client/src/sw/pipe.ts
  • packages/client/src/index.ts
  • packages/client/src/sw/precache.ts
  • packages/client/src/transport.ts
  • packages/client/test/browser/vite.config.ts
  • packages/client/src/worker/protocol.ts
  • packages/client/src/media/broker.test.ts
  • crates/engine/src/seams/http.rs
  • packages/client/src/media/range.test.ts
  • packages/client/src/media/registry.ts
  • apps/web/src/engine/createMediaService.ts
  • packages/client/src/media/broker.ts
  • packages/client/src/media/service.ts
  • packages/client/src/media/protocol.ts
  • packages/client/src/broadcast.ts
  • crates/wasm/src/host.rs
  • packages/client/src/sw/install.ts
  • crates/engine/src/facade.rs

Comment thread crates/engine/src/content/mod.rs Outdated
Comment thread crates/engine/tests/content_wipe.rs
Comment thread packages/client/src/sw/install.test.ts
A Zeroizing<Vec<u8>> wipes only the allocation it currently owns, so a
read wider than the prealloc budget freed the outgrown buffer with
plaintext still in it. Grow explicitly and let the old allocation drop
through Zeroizing, and pin it with an allocator-watchdog case whose
window exceeds the budget.

Also cover the malformed Content-Length fall-through in the capped
fetch seam.
@FSM1

FSM1 commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Nitpick from the 2026-08-02 review — packages/client/src/seams/http.test.ts, malformed Content-Length. Agreed and taken in 6592c0a; it has no inline thread, so replying here.

Both fall-throughs are now pinned as intended behaviour rather than an accident, as one it.each over the two malformed shapes:

  • abcNumber('abc') is NaN, so Number.isFinite rejects it
  • -1 — finite but under the cap, so the declared-size check does not trip

Either way the streaming cap is the only gate left, and the assertion is the same one the absent- and lying-small-header cases make: tooLarge at observed: 1100, body cancelled, no more than 1100 bytes pulled.

The proposed diff was adapted rather than pasted: it asserted body.cancelled() alongside an exact observed, but the streaming path only reaches the cap after pulling, so the existing suite's toBeLessThanOrEqual(1100) witness is the right one to reuse.

pnpm --filter @cipherbox/client test — 22 files, 227 tests passing.

@FSM1
FSM1 marked this pull request as ready for review August 2, 2026 21:56
@FSM1
FSM1 enabled auto-merge (squash) August 2, 2026 21:56
@FSM1
FSM1 merged commit 565a2d6 into main Aug 2, 2026
23 checks passed
@FSM1
FSM1 deleted the feat/641-sw-byte-pipe-precache branch August 2, 2026 21:59
FSM1 added a commit that referenced this pull request Aug 3, 2026
PR #951 added sendCapped to HttpSeam; ScriptedHttp now serves it from the
same scripted response queue and applies the inclusive cap.
FSM1 added a commit that referenced this pull request Aug 3, 2026
* fix: bring the browser mailbox seam onto the served API routes

The browser Mailbox seam posted raw octet-stream bytes with idempotency-key
and x-recipient headers, never polled, and treated an ack 404 as success --
a wire protocol the API has never served. Post and poll now speak the JSON
PostMessageDto and messages envelope on /mailbox/messages, ack rejects a 404
instead of recording a delete that never happened, and the seam owns its own
route suffix so the base URL is all a host supplies.

Wires the engine mailbox conformance kit into the browser conformance switch,
against an in-memory mock that enforces the same DTO validation and 8 KiB blob
bound as the API, so the seam is gated by the merge-blocking browser suite.

Closes #836

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnPEfCL8sJ8xAaqAvfJc9n

* test: handle aborted request bodies in the browser harness mocks

An abort rejects readBody; without a rejection handler the mock routing PUT
and echo endpoints leave it unhandled, which crashes the dev server under
Node's default unhandled-rejection policy and fails unrelated tests in the
same run. Matches the handled pattern already used by the mailbox POST.

* test: satisfy the capped HTTP seam in the mailbox test double

PR #951 added sendCapped to HttpSeam; ScriptedHttp now serves it from the
same scripted response queue and applies the inclusive cap.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web: land the Service Worker byte pipe and app-shell precache

1 participant