feat(mobile): read the bundle range capability and range replies

Adds the range reply reader and operation, and picks range or chunk from
the status.get capabilities the connection already proved, so an older
desktop keeps being paged in chunks with no probe round trip.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-22 22:16:10 -04:00
parent 6f5abf4bde
commit 040113ea96
4 changed files with 143 additions and 4 deletions
@@ -4,20 +4,22 @@ import {
MOBILE_WEB_BUNDLE_MANIFEST_METHOD,
type MobileWebBundleErrorCode
} from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract'
import { MOBILE_WEB_BUNDLE_RANGE_METHOD } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract'
import {
MobileWebBundleChunkReplySchema,
MobileWebBundleManifestReplySchema
MobileWebBundleManifestReplySchema,
MobileWebBundleRangeReplySchema
} from './mobile-web-bundle-reply-schemas'
import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { defineRpcOperation } from './rpc-operation'
import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
import { rpcResultVariant } from './rpc-operation-result-reader'
// The two reads that hand a paired phone the desktop's mobile web bundle. Both are
// The reads that hand a paired phone the desktop's mobile web bundle. All are
// `require-result-or-throw`: there is no partial success here, and a salvage policy would produce a
// half-bundle that fails a hash check much later, far from the cause. Both settle at `on-settle`,
// half-bundle that fails a hash check much later, far from the cause. All settle at `on-settle`,
// because each reply is acted on before the next request is built — the manifest decides which
// assets to page, and a chunk decides the next offset.
// assets to page, and a chunk or range decides the next offset.
/** The whole manifest plus the chunk size the host will serve it at. */
export const mobileWebBundleManifestRead = defineRpcOperation({
@@ -37,6 +39,16 @@ export const mobileWebBundleChunkRead = defineRpcOperation({
read: rpcResultVariant('mobile-web-bundle-chunk', MobileWebBundleChunkReplySchema)
})
/** Up to 384 KiB of one asset, gzipped when that shrinks it. Only sent to a host that advertised
* `mobileWeb.bundle.range.v1`; see `mobile-web-bundle-read-method.ts`. */
export const mobileWebBundleRangeRead = defineRpcOperation({
name: 'mobileWeb.bundle-range',
method: MOBILE_WEB_BUNDLE_RANGE_METHOD,
acceptance: 'require-result-or-throw',
barrier: 'on-settle',
read: rpcResultVariant('mobile-web-bundle-range', MobileWebBundleRangeReplySchema)
})
/** A code is a bare snake_case token, so only the two positions one can occupy are read. */
const LEADING_CODE_TOKEN = /^[a-z][a-z0-9_]*/
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH,
MOBILE_WEB_BUNDLE_RANGE_METHOD
} from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract'
import {
MOBILE_WEB_BUNDLE_CAPABILITY,
MOBILE_WEB_BUNDLE_RANGE_CAPABILITY
} from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES } from '../../../src/shared/mobile-web-bundle/manifest-contract'
import { mobileWebBundleRangeRead } from './mobile-web-bundle-operations'
import { mobileWebBundleReadMethodFor } from './mobile-web-bundle-read-method'
function rangeReply(overrides: Record<string, unknown> = {}) {
return {
buildId: 'a'.repeat(64),
path: 'index.html',
offset: 0,
assetByteLength: 12,
sha256: 'b'.repeat(64),
encoding: 'gzip',
dataBase64: 'aGVsbG8=',
eof: true,
...overrides
}
}
describe('mobile web bundle range reply reader', () => {
it('reads a range and passes an unknown member through', () => {
const result = mobileWebBundleRangeRead.read({ ...rangeReply(), later: 1 })
expect(result.compatible).toBe(true)
if (!result.compatible) {
return
}
expect(result.variant).toBe('mobile-web-bundle-range')
expect(result.value).toMatchObject({ encoding: 'gzip', later: 1 })
})
// Refusing it here would fail with a shape error; the decoder names the encoding instead.
it('reads an encoding this build does not know through to the decoder', () => {
expect(mobileWebBundleRangeRead.read(rangeReply({ encoding: 'br' })).compatible).toBe(true)
})
it('bounds dataBase64 at the range size the contract allows', () => {
const at = 'A'.repeat(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH)
expect(mobileWebBundleRangeRead.read(rangeReply({ dataBase64: at })).compatible).toBe(true)
expect(mobileWebBundleRangeRead.read(rangeReply({ dataBase64: `${at}A` })).compatible).toBe(
false
)
})
it('requires every member that makes a range self-describing', () => {
for (const overrides of [
{ buildId: 'nope' },
{ path: '../escape.js' },
{ offset: -1 },
{ assetByteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 },
{ sha256: 'b'.repeat(63) },
{ encoding: undefined },
{ encoding: '' },
{ dataBase64: undefined },
{ eof: undefined }
]) {
expect(mobileWebBundleRangeRead.read(rangeReply(overrides)).compatible).toBe(false)
}
})
it('is a require-result read of the range method', () => {
expect(mobileWebBundleRangeRead.method).toBe(MOBILE_WEB_BUNDLE_RANGE_METHOD)
expect(mobileWebBundleRangeRead.acceptance).toBe('require-result-or-throw')
expect(mobileWebBundleRangeRead.barrier).toBe('on-settle')
})
})
describe('which read method a host is paged with', () => {
const NEW_DESKTOP = [
'files.pathsExist',
MOBILE_WEB_BUNDLE_CAPABILITY,
MOBILE_WEB_BUNDLE_RANGE_CAPABILITY,
'terminal.quick-commands.v1'
]
/** Derived, never written down: the one string removed from what the new desktop sends. */
const OLD_DESKTOP = NEW_DESKTOP.filter(
(capability) => capability !== MOBILE_WEB_BUNDLE_RANGE_CAPABILITY
)
it('pages a new client against a new host in ranges', () => {
expect(mobileWebBundleReadMethodFor(NEW_DESKTOP)).toBe('range')
})
it('pages a new client against an old host in chunks', () => {
expect(mobileWebBundleReadMethodFor(OLD_DESKTOP)).toBe('chunk')
expect(mobileWebBundleReadMethodFor([])).toBe('chunk')
})
})
@@ -0,0 +1,16 @@
import { MOBILE_WEB_BUNDLE_RANGE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
/** `range` pages 384 KiB gzipped windows; `chunk` pages the 48 KiB raw ones every bundle host serves. */
export type MobileWebBundleReadMethod = 'range' | 'chunk'
/**
* Read off the `status.get` capabilities this connection already proved, never probed: a phone
* calling a method an older desktop never allowlisted is told `forbidden`, not `method_not_found`,
* so a probe would need to read an authorization code as absence. A host without the capability
* keeps being paged in chunks, which is what it has always served.
*/
export function mobileWebBundleReadMethodFor(
hostCapabilities: readonly string[]
): MobileWebBundleReadMethod {
return hostCapabilities.includes(MOBILE_WEB_BUNDLE_RANGE_CAPABILITY) ? 'range' : 'chunk'
}
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract'
import { MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract'
import {
computeMobileWebBundleId,
MobileWebBundleAssetPathSchema,
@@ -129,6 +130,20 @@ export const MobileWebBundleChunkReplySchema = z.looseObject({
eof: z.boolean()
})
/** A chunk's self-description plus the encoding of `dataBase64`. `encoding` is read as a string, not
* a closed enum: an encoding this build cannot decode is a typed refusal at the decoder, which
* names it, rather than a reply-shape failure that names nothing. */
export const MobileWebBundleRangeReplySchema = z.looseObject({
buildId: z.string().regex(SHA256_PATTERN),
path: MobileWebBundleAssetPathSchema,
offset: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES),
assetByteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES),
sha256: z.string().regex(SHA256_PATTERN),
encoding: z.string().min(1).max(32),
dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH),
eof: z.boolean()
})
export type MobileWebBundleManifestReply = z.output<typeof MobileWebBundleManifestReplySchema>
export type MobileWebBundleManifestRead = MobileWebBundleManifestReply['manifest']
export type MobileWebBundleAssetRead = MobileWebBundleManifestRead['assets'][number]