feat(mobile): read gzipped bundle ranges on the pipelined window

A host that advertised mobileWeb.bundle.range.v1 is paged in 384 KiB
ranges on the range grid, through the same four-read window and queue as
chunks; any other host keeps the chunk grid. Each range is decoded to its
exact window length before the fill checks and the asset hash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-22 22:38:17 -04:00
parent 3eea80a157
commit 252c592b52
3 changed files with 320 additions and 23 deletions
@@ -0,0 +1,226 @@
import { sha256 } from '@noble/hashes/sha256'
import { gzipSync } from 'fflate'
import { describe, expect, it, vi } from 'vitest'
import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract'
import { MOBILE_WEB_BUNDLE_RANGE_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract'
import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract'
import { fetchMobileWebBundle } from './mobile-web-bundle-fetch'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import type { MobileWebBundleReadMethod } from './mobile-web-bundle-read-method'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function encodeBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
/** Script-like: repetitive enough to gzip well, varied enough that a misplaced window shows. */
function scriptBytes(byteLength: number, seed: number): Uint8Array {
return Uint8Array.from({ length: byteLength }, (_, index) =>
index % 97 === 0 ? (seed + index / 97) % 256 : 97 + ((index * 7 + seed) % 26)
)
}
type HostCall = { method: string; params: Record<string, unknown> }
/**
* A host that serves both read methods by the real one's rules: ranges on the caller's own grid,
* gzipped at level 6 when that shrinks them. `tamper` replaces the body of one range reply.
*/
function rangeHost(
files: Record<string, Uint8Array>,
options: { tamper?: (call: HostCall, body: string) => string } = {}
) {
const assets = Object.entries(files)
.map(([path, content]) => ({
path,
sha256: toHex(sha256(content)),
byteLength: content.byteLength,
contentType: 'text/javascript'
}))
.sort((left, right) => (left.path < right.path ? -1 : 1))
const buildId = computeMobileWebBundleId(assets)
const manifest = {
schemaVersion: 1,
buildId,
desktopVersion: '1.4.200',
minCompatibleRuntimeProtocolVersion: 2,
runtimeProtocolVersion: 2,
entrypoint: assets[0]!.path,
totalBytes: assets.reduce((total, entry) => total + entry.byteLength, 0),
assets
}
const calls: HostCall[] = []
let wireBase64Bytes = 0
let inFlight = 0
let peakInFlight = 0
const answer = (call: HostCall): unknown => {
if (call.method === 'mobileWeb.bundle.manifest') {
return { manifest, chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES }
}
const path = String(call.params.path)
const offset = Number(call.params.offset)
const content = files[path]!
const length =
call.method === 'mobileWeb.bundle.range'
? Number(call.params.length)
: MOBILE_WEB_BUNDLE_CHUNK_BYTES
const slice = content.subarray(offset, offset + length)
const common = {
buildId,
path,
offset,
assetByteLength: content.byteLength,
sha256: toHex(sha256(content)),
eof: offset + slice.byteLength >= content.byteLength
}
if (call.method === 'mobileWeb.bundle.chunk') {
return { ...common, dataBase64: encodeBase64(slice) }
}
const gzipped = gzipSync(slice, { level: 6 })
const encoding = gzipped.byteLength < slice.byteLength ? 'gzip' : 'identity'
const body = encodeBase64(encoding === 'gzip' ? gzipped : slice)
return { ...common, encoding, dataBase64: options.tamper?.(call, body) ?? body }
}
const client: RpcClient = {
sendRequest: vi.fn(async (method: string, params?: unknown): Promise<RpcResponse> => {
const call: HostCall = { method, params: Object(params) }
calls.push(call)
inFlight += 1
peakInFlight = Math.max(peakInFlight, inFlight)
try {
await new Promise((resolve) => setTimeout(resolve, 0))
const result = answer(call)
const body: unknown = Object(result).dataBase64
wireBase64Bytes += typeof body === 'string' ? body.length : 0
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
} finally {
inFlight -= 1
}
}),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => 1,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
return {
client,
calls,
wireBase64Bytes: () => wireBase64Bytes,
peakInFlight: () => peakInFlight
}
}
/** One asset larger than a range, so the range grid pages it more than once. */
const FILES = {
'assets/app.js': scriptBytes(MOBILE_WEB_BUNDLE_RANGE_BYTES * 2 + 5000, 1),
'assets/vendor.js': scriptBytes(120_000, 2),
'index.html': scriptBytes(600, 3)
}
function readsOf(calls: readonly HostCall[], method: string): HostCall[] {
return calls.filter((call) => call.method === method)
}
async function fetchWith(readMethod: MobileWebBundleReadMethod, files = FILES) {
const host = rangeHost(files)
const fetched = await fetchMobileWebBundle({ client: host.client, readMethod })
return { host, fetched }
}
async function refusalOf(failed: Promise<unknown>): Promise<string | null> {
const error = await failed.then(
() => null,
(thrown: unknown) => thrown
)
return error instanceof MobileWebBundleFetchError ? error.refusal : null
}
describe('fetchMobileWebBundle over ranges', () => {
it('pages every asset in ranges on the range grid and returns the verified bytes', async () => {
const { host, fetched } = await fetchWith('range')
for (const [path, content] of Object.entries(FILES)) {
expect(fetched.assets.get(path)).toEqual(content)
}
expect(readsOf(host.calls, 'mobileWeb.bundle.chunk')).toHaveLength(0)
const ranges = readsOf(host.calls, 'mobileWeb.bundle.range')
// 3 for the large asset, 1 each for the other two.
expect(ranges).toHaveLength(5)
for (const range of ranges) {
expect(range.params.length).toBe(MOBILE_WEB_BUNDLE_RANGE_BYTES)
expect(Number(range.params.offset) % MOBILE_WEB_BUNDLE_RANGE_BYTES).toBe(0)
}
expect(host.peakInFlight()).toBeLessThanOrEqual(4)
})
// The discovery is the status capability the session already read, never a request of its own.
it('sends nothing but the manifest and the reads to decide the method', async () => {
const { host } = await fetchWith('range')
expect(
host.calls.filter(
(call) =>
call.method !== 'mobileWeb.bundle.range' && call.method !== 'mobileWeb.bundle.manifest'
)
).toEqual([])
})
it('pages a host without the range capability in chunks, as before', async () => {
const { host, fetched } = await fetchWith('chunk')
expect(fetched.assets.get('assets/app.js')).toEqual(FILES['assets/app.js'])
expect(readsOf(host.calls, 'mobileWeb.bundle.range')).toHaveLength(0)
expect(readsOf(host.calls, 'mobileWeb.bundle.chunk').length).toBeGreaterThan(5)
})
it('carries the same bundle in fewer round trips and fewer bytes than chunks', async () => {
const chunked = await fetchWith('chunk')
const ranged = await fetchWith('range')
// 17 + 3 + 1 chunks at 48 KiB against 3 + 1 + 1 ranges at 384 KiB.
expect(readsOf(chunked.host.calls, 'mobileWeb.bundle.chunk')).toHaveLength(21)
expect(readsOf(ranged.host.calls, 'mobileWeb.bundle.range')).toHaveLength(5)
expect(ranged.host.wireBase64Bytes()).toBeLessThan(chunked.host.wireBase64Bytes() / 2)
})
it('refuses a corrupt gzip range as undecodable', async () => {
const host = rangeHost(FILES, {
tamper: (call, body) =>
call.params.path === 'assets/app.js' && call.params.offset === 0
? encodeBase64(Uint8Array.of(0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3, 1, 2, 3))
: body
})
expect(
await refusalOf(fetchMobileWebBundle({ client: host.client, readMethod: 'range' }))
).toBe('range-undecodable')
})
it('refuses a range that decodes to the wrong length', async () => {
const host = rangeHost(FILES, {
tamper: (call, body) =>
call.params.path === 'index.html'
? encodeBase64(gzipSync(scriptBytes(599, 3), { level: 6 }))
: body
})
expect(
await refusalOf(fetchMobileWebBundle({ client: host.client, readMethod: 'range' }))
).toBe('range-length-mismatch')
})
})
+22 -23
View File
@@ -1,6 +1,5 @@
import { sha256 } from '@noble/hashes/sha256'
import {
mobileWebBundleChunkRead,
mobileWebBundleManifestRead,
readMobileWebBundleErrorCode
} from './mobile-web-bundle-operations'
@@ -12,11 +11,17 @@ import type { RpcClient } from './rpc-client'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import type { MobileWebBundleReadMethod } from './mobile-web-bundle-read-method'
import { runRpcOperation } from './rpc-operation'
import {
decodeMobileWebBundleWindow,
mobileWebBundleWindowBytes,
requestMobileWebBundleWindow
} from './mobile-web-bundle-window-read'
/** The host refuses the fifth concurrent read on one connection with `mobile_web_bundle_read_limited`,
* so the client never offers a fifth. The four are chunk reads across the whole manifest, not one
* asset each: paging a large asset alone would put every one of its chunks on the critical path. */
const MAX_CONCURRENT_ASSET_READS = 4
* so the client never offers a fifth. The four are chunk or range reads across the whole manifest,
* not one asset each: paging a large asset alone would put every one of its reads on the critical
* path. */
const MAX_CONCURRENT_WINDOW_READS = 4
export type MobileWebBundleFetchProgress = {
readonly completedAssets: number
@@ -62,20 +67,24 @@ export async function fetchMobileWebBundle(args: {
const manifest = opened.manifest
const assets = new Map<string, Uint8Array>()
let receivedBytes = 0
const method = args.readMethod ?? 'chunk'
const windowBytes = mobileWebBundleWindowBytes(method, opened.chunkBytes)
const readChunk = async (read: ChunkRead): Promise<void> => {
const chunk = await runRpcOperation(args.client, mobileWebBundleChunkRead, {
const chunk = await requestMobileWebBundleWindow(args.client, method, {
buildId: manifest.buildId,
path: read.asset.entry.path,
offset: read.offset
offset: read.offset,
length: windowBytes
})
// A sibling already failed the fetch; this reply is not worth checking or hashing.
// A sibling already failed the fetch; this reply is not worth checking, decoding or hashing.
if (stopped.signal.aborted || read.asset.whole === null) {
return
}
assertChunkDescribesAsset(chunk, read.asset.entry, manifest.buildId, read.offset)
const bytes = decodeBase64(chunk.dataBase64)
assertChunkFillsItsSlot(read, bytes.byteLength, chunk.eof, opened.chunkBytes)
const expected = Math.min(windowBytes, read.asset.entry.byteLength - read.offset)
const bytes = decodeMobileWebBundleWindow(method, chunk, expected)
assertChunkFillsItsSlot(read, bytes.byteLength, chunk.eof, windowBytes)
const { whole } = read.asset
const { offset } = read
whole.set(bytes, offset)
@@ -95,7 +104,7 @@ export async function fetchMobileWebBundle(args: {
}
await runChunkWindow({
reads: planChunkReads(manifest.assets, opened.chunkBytes),
reads: planChunkReads(manifest.assets, windowBytes),
readChunk,
signal: args.signal,
stopped
@@ -104,7 +113,7 @@ export async function fetchMobileWebBundle(args: {
}
/** Largest asset first, so the biggest script's tail is never the last read left in flight. Offsets
* are the host's chunk grid, so every read is known up front; `eof` still comes from the reply. */
* are the chunk or range grid, so every read is known up front; `eof` still comes from the reply. */
function planChunkReads(
entries: readonly MobileWebBundleAssetRead[],
chunkBytes: number
@@ -123,7 +132,7 @@ function planChunkReads(
}
/**
* Keeps up to four chunk reads in flight over one queue. A `read_limited` refusal means something
* Keeps up to four chunk or range reads in flight over one queue. A `read_limited` refusal means something
* else holds one of the host's slots: the window narrows once per refusal at the current width and
* the read is retried; a refusal of a read sent alone is the host's verdict and fails the fetch.
*/
@@ -134,7 +143,7 @@ function runChunkWindow(args: {
stopped: AbortController
}): Promise<void> {
const queue = args.reads
let width = MAX_CONCURRENT_ASSET_READS
let width = MAX_CONCURRENT_WINDOW_READS
let inFlight = 0
return new Promise((resolve, reject) => {
// One failed chunk stops every other read, not just the next: each read it would still send
@@ -300,16 +309,6 @@ function throwIfStopped(caller: AbortSignal | undefined, stopped: AbortSignal):
}
}
/** Metro ships no Buffer; `atob` is the decoder the pairing and E2EE paths already run on Hermes. */
function decodeBase64(value: string): Uint8Array {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
@@ -0,0 +1,72 @@
import { MOBILE_WEB_BUNDLE_RANGE_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract'
import { mobileWebBundleChunkRead, mobileWebBundleRangeRead } from './mobile-web-bundle-operations'
import { decodeMobileWebBundleRange } from './mobile-web-bundle-range-decode'
import type { MobileWebBundleReadMethod } from './mobile-web-bundle-read-method'
import type { RpcClient } from './rpc-client'
import { runRpcOperation } from './rpc-operation'
/** What either read method's reply restates about the window it answers. */
export type MobileWebBundleWindowReply = {
readonly buildId: string
readonly path: string
readonly offset: number
readonly assetByteLength: number
readonly sha256: string
readonly eof: boolean
readonly dataBase64: string
/** Present on a range reply only; a chunk's body is always raw. */
readonly encoding?: string
}
/** The grid the fetch plans offsets on: the host's advertised chunk size, or the range ceiling. */
export function mobileWebBundleWindowBytes(
method: MobileWebBundleReadMethod,
chunkBytes: number
): number {
return method === 'range' ? MOBILE_WEB_BUNDLE_RANGE_BYTES : chunkBytes
}
export async function requestMobileWebBundleWindow(
client: RpcClient,
method: MobileWebBundleReadMethod,
window: { buildId: string; path: string; offset: number; length: number }
): Promise<MobileWebBundleWindowReply> {
if (method === 'range') {
return runRpcOperation(client, mobileWebBundleRangeRead, window)
}
return runRpcOperation(client, mobileWebBundleChunkRead, {
buildId: window.buildId,
path: window.path,
offset: window.offset
})
}
/**
* The raw bytes of a window. A range is decoded to exactly `expectedLength` or refused; a chunk is
* returned as sent, because its length checks against the chunk grid live with the fetch.
*/
export function decodeMobileWebBundleWindow(
method: MobileWebBundleReadMethod,
reply: MobileWebBundleWindowReply,
expectedLength: number
): Uint8Array {
const wire = decodeBase64(reply.dataBase64)
if (method === 'chunk') {
return wire
}
return decodeMobileWebBundleRange(
{ path: reply.path, offset: reply.offset, encoding: reply.encoding ?? '' },
wire,
expectedLength
)
}
/** Metro ships no Buffer; `atob` is the decoder the pairing and E2EE paths already run on Hermes. */
function decodeBase64(value: string): Uint8Array {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes
}