From 9ce2c8cd3c55c2af19bde16b54f9e1c86430fcdd Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 7 Aug 2026 15:34:04 +0900 Subject: [PATCH 1/3] test: transform the JSX of the CodePush module for jest The Babel config of this repository has no JSX transform - an app bundling the library transforms it with the React Native preset - so a test cannot load src/CodePush.js. Compile that one file with TypeScript instead, which turns the JSX of the decorator into React.createElement calls and leaves the rest of the module to the same downlevelling Babel would have applied. --- jest.config.js | 11 +++++++++++ jest.jsxTransformer.cjs | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 jest.config.js create mode 100644 jest.jsxTransformer.cjs diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..64aadbd10 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('jest').Config} */ +module.exports = { + rootDir: __dirname, + testEnvironment: 'node', + transform: { + // The only source file with JSX, which Babel is not set up to transform here. + 'src/CodePush\\.js$': '/jest.jsxTransformer.cjs', + '^.+\\.(t|j)sx?$': 'babel-jest', + }, + watchman: false, +}; diff --git a/jest.jsxTransformer.cjs b/jest.jsxTransformer.cjs new file mode 100644 index 000000000..ab585437c --- /dev/null +++ b/jest.jsxTransformer.cjs @@ -0,0 +1,41 @@ +const crypto = require('crypto'); +const ts = require('typescript'); + +/** + * `src/CodePush.js` carries the JSX of the `codePush` decorator, and the Babel config of + * this repository has no JSX transform - an app bundling the library transforms it with + * the React Native preset. Tests still have to load the module, so it is compiled with + * TypeScript instead, which turns the JSX into `React.createElement` calls and leaves the + * rest of the file to the same downlevelling Babel would have applied. + */ +const COMPILER_OPTIONS = { + allowJs: true, + esModuleInterop: true, + jsx: ts.JsxEmit.React, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, +}; + +module.exports = { + process(sourceText, sourcePath) { + const { outputText } = ts.transpileModule(sourceText, { + fileName: sourcePath, + compilerOptions: COMPILER_OPTIONS, + }); + + return { code: outputText }; + }, + + getCacheKey(sourceText, sourcePath) { + return crypto + .createHash('sha1') + .update(ts.version) + .update('\0', 'utf8') + .update(JSON.stringify(COMPILER_OPTIONS)) + .update('\0', 'utf8') + .update(sourcePath) + .update('\0', 'utf8') + .update(sourceText) + .digest('hex'); + }, +}; From 2dac7601bafef11749872730002b8857015e5a50 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 7 Aug 2026 15:34:14 +0900 Subject: [PATCH 2/3] feat: add optional binary patch metadata A release published with --binary-bundle-path uploads a patch archive whose URL was logged and then dropped. Record it in the release history entry of that release, and carry it from the fetched history through the update check to the metadata the native module is handed when it downloads the update. A release without a patch says nothing about one: the field is absent from the history entry and from the update, so a release history written before binary patches existed keeps behaving exactly as it did. --- .../releaseCommand/addToReleaseHistory.ts | 7 + cli/commands/releaseCommand/release.test.ts | 10 +- cli/commands/releaseCommand/release.ts | 4 +- package.json | 2 +- src/CodePush.js | 10 + src/CodePush.test.js | 214 ++++++++++++++++++ typings/react-native-code-push.d.ts | 20 ++ 7 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/CodePush.test.js diff --git a/cli/commands/releaseCommand/addToReleaseHistory.ts b/cli/commands/releaseCommand/addToReleaseHistory.ts index c98c01c87..0619edcbc 100644 --- a/cli/commands/releaseCommand/addToReleaseHistory.ts +++ b/cli/commands/releaseCommand/addToReleaseHistory.ts @@ -6,6 +6,7 @@ export async function addToReleaseHistory( appVersion: string, binaryVersion: string, bundleDownloadUrl: string, + binaryPatchDownloadUrl: string | undefined, packageHash: string, getReleaseHistory: CliConfigInterface['getReleaseHistory'], setReleaseHistory: CliConfigInterface['setReleaseHistory'], @@ -32,6 +33,12 @@ export async function addToReleaseHistory( packageHash: packageHash, }; + // A release without a binary patch says nothing about one, so that a client reading + // this history downloads the full bundle exactly as it did before patches existed. + if (binaryPatchDownloadUrl) { + newReleaseHistory[appVersion].binaryPatchDownloadUrl = binaryPatchDownloadUrl; + } + if (typeof rollout === 'number') { newReleaseHistory[appVersion].rollout = rollout; } diff --git a/cli/commands/releaseCommand/release.test.ts b/cli/commands/releaseCommand/release.test.ts index da94c39cf..a273b684d 100644 --- a/cli/commands/releaseCommand/release.test.ts +++ b/cli/commands/releaseCommand/release.test.ts @@ -203,6 +203,9 @@ describe("release without --binary-bundle-path", () => { downloadUrl: uploads[0].downloadUrl, packageHash: staged.bundleFileName, }); + // A release without a patch says nothing about one, so a client reading this + // history behaves exactly as it did before binary patches existed. + expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('binaryPatchDownloadUrl'); }); }); @@ -217,9 +220,10 @@ describe("release --skip-bundle --binary-bundle-path", () => { expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([staged.bundleFileName, patchFileName]); expect(fs.existsSync(path.join(staged.bundleDirectory, patchFileName))).toBe(true); - // Carrying the patch URL in the release history is a separate concern; for now - // the history keeps describing the full bundle only. + // Both artifacts are described in the same entry: the full bundle every client can + // download, and the patch a client holding the matching binary can apply instead. expect(releaseHistories[0][APP_VERSION].downloadUrl).toBe(uploads[0].downloadUrl); + expect(releaseHistories[0][APP_VERSION].binaryPatchDownloadUrl).toBe(uploads[1].downloadUrl); expect(releaseHistories[0][APP_VERSION].packageHash).toBe(staged.bundleFileName); }); @@ -424,6 +428,8 @@ describe("release --on-oversized-patch", () => { expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/not smaller than the full archive/); expect(logs.join('\n')).toContain('Patch skipped:'); expect(releaseHistories[0][APP_VERSION].downloadUrl).toBe(uploads[0].downloadUrl); + // Nothing was uploaded to patch from, so the entry must not point at one. + expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('binaryPatchDownloadUrl'); }); it("fails before any upload when the policy is fail", async () => { diff --git a/cli/commands/releaseCommand/release.ts b/cli/commands/releaseCommand/release.ts index 896e6ae54..99dcbec89 100644 --- a/cli/commands/releaseCommand/release.ts +++ b/cli/commands/releaseCommand/release.ts @@ -72,8 +72,9 @@ export async function release( // Every artifact is uploaded before the release history is touched, so a failed // upload leaves the history describing only updates that can actually be downloaded. const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, 'bundle'); + let patchDownloadUrl: string | undefined; if (binaryPatch) { - const patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle'); + patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle'); console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`); } @@ -81,6 +82,7 @@ export async function release( appVersion, binaryVersion, downloadUrl, + patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, diff --git a/package.json b/package.json index 92b5b2cd3..8bacd7954 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "build:cli": "npm run --workspace cli clean && npm run --workspace cli build", "prepack": "npm run build:cli", "eslint": "eslint --quiet .", - "jest": "jest src/versioning/* expo/* && npm run --workspace cli test", + "jest": "jest src/CodePush.test.js src/versioning/* expo/* && npm run --workspace cli test", "e2e": "ts-node --project e2e/tsconfig.json e2e/run.ts" }, "publishConfig": { diff --git a/src/CodePush.js b/src/CodePush.js index 71930a9e9..1f90131c4 100644 --- a/src/CodePush.js +++ b/src/CodePush.js @@ -181,6 +181,11 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) { */ const updateInfo = { download_url: latestReleaseInfo.downloadUrl, + /** + * Only released updates that were published with a binary patch carry this. + * When it is missing, the update is downloaded in full from `download_url`. + */ + binary_patch_download_url: latestReleaseInfo.binaryPatchDownloadUrl, // (`enabled` will always be true in the release information obtained from the previous process.) is_available: latestReleaseInfo.enabled, package_hash: latestReleaseInfo.packageHash, @@ -281,6 +286,11 @@ function mapToRemotePackageMetadata(updateInfo) { packageHash: updateInfo.package_hash ?? '', packageSize: updateInfo.package_size ?? 0, downloadUrl: updateInfo.download_url ?? '', + // The field stays out of the package unless the update really has a binary patch, + // so that the native side sees exactly what it saw before patches existed. + ...(updateInfo.binary_patch_download_url + ? { binaryPatchDownloadUrl: updateInfo.binary_patch_download_url } + : {}), }; } diff --git a/src/CodePush.test.js b/src/CodePush.test.js new file mode 100644 index 000000000..2a1aa0a32 --- /dev/null +++ b/src/CodePush.test.js @@ -0,0 +1,214 @@ +/** + * A binary patch is announced in the release history and consumed by the native side, + * which only ever sees the object handed to `downloadUpdate`. These cases pin that path: + * what the release history says, what `checkForUpdate` resolves to, and what the native + * module is given when the update is downloaded. + */ + +jest.mock('react-native', () => ({ + Alert: { alert: jest.fn() }, + AppState: { addEventListener: jest.fn(() => ({ remove: jest.fn() })) }, + NativeEventEmitter: class NativeEventEmitter { + addListener() { + return { remove: jest.fn() }; + } + }, + NativeModules: {}, + Platform: { OS: 'ios' }, + TurboModuleRegistry: { get: () => null }, +})); + +const BINARY_VERSION = '1.0.0'; +const LABEL = '1.0.1'; +const PACKAGE_HASH = 'a'.repeat(64); +const DOWNLOAD_URL = 'https://cdn.example.com/full.zip'; +const BINARY_PATCH_DOWNLOAD_URL = 'https://cdn.example.com/full.zip-patch.zip'; + +/** The release the CLI writes when only the full bundle was published. */ +function fullOnlyRelease() { + return { + [LABEL]: { + enabled: true, + mandatory: false, + downloadUrl: DOWNLOAD_URL, + packageHash: PACKAGE_HASH, + }, + }; +} + +function createNativeBridge() { + return { + addDownloadProgressListener: jest.fn(() => ({ remove: jest.fn() })), + downloadUpdate: jest.fn(async (updatePackage) => ({ ...updatePackage })), + // No CodePush update is installed, so the app runs the bundle of its binary. + getUpdateMetadata: jest.fn(async () => null), + isFailedUpdate: jest.fn(async () => false), + isFirstRun: jest.fn(async () => false), + }; +} + +/** + * Loads a fresh copy of the module - the options passed to `codePush()` and the injected + * native bridge live on the module itself - configured the way an app configures it. + */ +function loadCodePush({ releaseHistory = {}, updateChecker } = {}) { + jest.resetModules(); + const CodePush = require('./CodePush'); + const nativeBridge = createNativeBridge(); + + CodePush.setUpTestDependencies( + null, + { appVersion: BINARY_VERSION, clientUniqueId: 'test-client-id' }, + nativeBridge, + ); + CodePush({ + releaseHistoryFetcher: async () => releaseHistory, + updateChecker, + }); + + return { CodePush, nativeBridge }; +} + +/** The metadata the native module is given, without the functions mixed into the package. */ +function downloadedPackageMetadata(nativeBridge) { + expect(nativeBridge.downloadUpdate).toHaveBeenCalledTimes(1); + return nativeBridge.downloadUpdate.mock.calls[0][0]; +} + +describe('checkForUpdate with a binary patch in the release history', () => { + it('carries the patch url into the update and on to the native module', async () => { + const { CodePush, nativeBridge } = loadCodePush({ + releaseHistory: { + [LABEL]: { + ...fullOnlyRelease()[LABEL], + binaryPatchDownloadUrl: BINARY_PATCH_DOWNLOAD_URL, + }, + }, + }); + + const remotePackage = await CodePush.checkForUpdate(); + + expect(remotePackage.downloadUrl).toBe(DOWNLOAD_URL); + expect(remotePackage.binaryPatchDownloadUrl).toBe(BINARY_PATCH_DOWNLOAD_URL); + + await remotePackage.download(); + + expect(downloadedPackageMetadata(nativeBridge)).toMatchObject({ + downloadUrl: DOWNLOAD_URL, + binaryPatchDownloadUrl: BINARY_PATCH_DOWNLOAD_URL, + label: LABEL, + packageHash: PACKAGE_HASH, + }); + }); + + it('reads the patch url of the release being installed, not of another one', async () => { + const { CodePush } = loadCodePush({ + releaseHistory: { + '1.0.1': { + enabled: true, + mandatory: false, + downloadUrl: 'https://cdn.example.com/older.zip', + binaryPatchDownloadUrl: 'https://cdn.example.com/older.zip-patch.zip', + packageHash: 'b'.repeat(64), + }, + '1.0.2': { + enabled: true, + mandatory: false, + downloadUrl: DOWNLOAD_URL, + binaryPatchDownloadUrl: BINARY_PATCH_DOWNLOAD_URL, + packageHash: PACKAGE_HASH, + }, + }, + }); + + const remotePackage = await CodePush.checkForUpdate(); + + expect(remotePackage.label).toBe('1.0.2'); + expect(remotePackage.binaryPatchDownloadUrl).toBe(BINARY_PATCH_DOWNLOAD_URL); + }); +}); + +describe('checkForUpdate without a binary patch in the release history', () => { + it('leaves the patch url out of the update and of the native metadata', async () => { + const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: fullOnlyRelease() }); + + const remotePackage = await CodePush.checkForUpdate(); + + expect(remotePackage.downloadUrl).toBe(DOWNLOAD_URL); + expect(remotePackage).not.toHaveProperty('binaryPatchDownloadUrl'); + + await remotePackage.download(); + + expect(downloadedPackageMetadata(nativeBridge)).not.toHaveProperty('binaryPatchDownloadUrl'); + }); + + it('serves a release history written before binary patches existed', async () => { + const { CodePush } = loadCodePush({ + releaseHistory: { + [BINARY_VERSION]: { + enabled: true, + mandatory: false, + downloadUrl: 'https://cdn.example.com/binary.zip', + packageHash: 'c'.repeat(64), + }, + [LABEL]: { + enabled: true, + mandatory: true, + downloadUrl: DOWNLOAD_URL, + packageHash: PACKAGE_HASH, + rollout: 100, + }, + }, + }); + + const remotePackage = await CodePush.checkForUpdate(); + + expect(remotePackage).toMatchObject({ + label: LABEL, + appVersion: BINARY_VERSION, + downloadUrl: DOWNLOAD_URL, + packageHash: PACKAGE_HASH, + isMandatory: true, + }); + expect(remotePackage).not.toHaveProperty('binaryPatchDownloadUrl'); + }); +}); + +describe('checkForUpdate through the deprecated updateChecker', () => { + it('carries the patch url of the update check response', async () => { + const { CodePush } = loadCodePush({ + updateChecker: async () => ({ + update_info: { + is_available: true, + download_url: DOWNLOAD_URL, + binary_patch_download_url: BINARY_PATCH_DOWNLOAD_URL, + target_binary_range: BINARY_VERSION, + label: LABEL, + package_hash: PACKAGE_HASH, + }, + }), + }); + + const remotePackage = await CodePush.checkForUpdate(); + + expect(remotePackage.binaryPatchDownloadUrl).toBe(BINARY_PATCH_DOWNLOAD_URL); + }); + + it('leaves it out when the update check response has none', async () => { + const { CodePush } = loadCodePush({ + updateChecker: async () => ({ + update_info: { + is_available: true, + download_url: DOWNLOAD_URL, + target_binary_range: BINARY_VERSION, + label: LABEL, + package_hash: PACKAGE_HASH, + }, + }), + }); + + const remotePackage = await CodePush.checkForUpdate(); + + expect(remotePackage).not.toHaveProperty('binaryPatchDownloadUrl'); + }); +}); diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index afbec867a..aea1d0420 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -28,6 +28,12 @@ export interface ReleaseInfo { enabled: boolean; mandatory: boolean; downloadUrl: string; + /** + * The URL of a binary patch archive built against the JS bundle embedded in the app binary. + * It is only present when the release was published together with such a patch, and a client + * that cannot use it downloads the full update from `downloadUrl` instead. + */ + binaryPatchDownloadUrl?: string; packageHash: string; rollout?: number; } @@ -35,6 +41,12 @@ export interface ReleaseInfo { // from code-push SDK export interface UpdateCheckResponse { download_url?: string; + /** + * The URL of a binary patch archive built against the JS bundle embedded in the app binary. + * It is only present when the release was published together with such a patch, and a client + * that cannot use it downloads the full update from `download_url` instead. + */ + binary_patch_download_url?: string; description?: string; is_available: boolean; is_disabled?: boolean; @@ -192,6 +204,14 @@ export interface RemotePackage extends Package { * The URL at which the package is available for download. */ downloadUrl: string; + + /** + * The URL at which a binary patch archive of this package is available for download. + * The patch is built against the JS bundle embedded in the app binary, so it is only + * present when the release was published together with one. When it is absent, the + * package is downloaded in full from `downloadUrl`. + */ + binaryPatchDownloadUrl?: string; } export interface SyncOptions { From 3aa4268d21996db6e6096f5e81732d6f40e0247b Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 7 Aug 2026 15:34:20 +0900 Subject: [PATCH 3/3] docs: describe the binary patch url of a release --- cli/README.ko.md | 9 +++++++-- cli/README.md | 10 ++++++++-- docs/api-js.md | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cli/README.ko.md b/cli/README.ko.md index 5c3075af0..ce6f50aa3 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -114,7 +114,8 @@ npx code-push release [options] 이름의 full 번들과, 바이너리에 포함된 번들과의 차이만 담은 `-patch.zip` patch 번들입니다. patch 번들에는 업데이트 복원 방법을 담은 `codepush-binary-patch.json` manifest가 포함되어, patch를 적용하면 full 번들과 동일한 `packageHash`가 됩니다. 두 artifact의 크기와 -절감량은 업로드 전에 출력됩니다. +절감량은 업로드 전에 출력됩니다. 릴리스 히스토리 항목에는 full 번들 URL과 함께 patch 번들을 +내려받을 수 있는 URL이 기록됩니다. patch는 대체하려는 archive보다 작을 때만 배포할 가치가 있습니다. CLI는 사용자에게 묻지 않으므로, patch 크기가 full 이상일 때의 동작을 `--on-oversized-patch`로 미리 정합니다. @@ -248,11 +249,15 @@ npx code-push show-history -b 1.0.0 -p ios "enabled": true, "mandatory": true, "downloadUrl": "https://storage.example.com/bundles/ios/staging/d4e5f6...", - "packageHash": "d4e5f6..." + "packageHash": "d4e5f6...", + "binaryPatchDownloadUrl": "https://storage.example.com/bundles/ios/staging/d4e5f6...-patch.zip" } } ``` +`binaryPatchDownloadUrl`은 `--binary-bundle-path`로 배포한 릴리스에만 기록됩니다. 그 외의 +릴리스에는 이 필드가 없으며, binary patch 이전에 작성된 히스토리도 그대로 유효합니다. + ## 일반적인 워크플로우 ``` diff --git a/cli/README.md b/cli/README.md index a2949e0ac..b063889fb 100644 --- a/cli/README.md +++ b/cli/README.md @@ -113,7 +113,8 @@ bundle named after its `packageHash`, and a patch bundle named `-pa that carries only the difference from the bundle inside the binary. The patch bundle holds a `codepush-binary-patch.json` manifest describing how to rebuild the update, so applying it yields the same `packageHash` as the full bundle. Both sizes and the saving -are printed before either artifact is uploaded. +are printed before either artifact is uploaded. The release history entry records where +the patch bundle can be downloaded, next to the full bundle URL. A patch is only worth publishing when it is smaller than the archive it replaces. The CLI never prompts, so `--on-oversized-patch` decides in advance what happens when the patch @@ -240,11 +241,16 @@ The release history is a JSON object keyed by app version. For example, the hist "enabled": true, "mandatory": true, "downloadUrl": "https://storage.example.com/bundles/ios/staging/d4e5f6...", - "packageHash": "d4e5f6..." + "packageHash": "d4e5f6...", + "binaryPatchDownloadUrl": "https://storage.example.com/bundles/ios/staging/d4e5f6...-patch.zip" } } ``` +`binaryPatchDownloadUrl` is only written for a release published with +`--binary-bundle-path`. Every other release leaves the field out, and a history written +before binary patches existed stays valid as it is. + ## Typical Workflow ``` diff --git a/docs/api-js.md b/docs/api-js.md index 0f946a102..ac29eed70 100644 --- a/docs/api-js.md +++ b/docs/api-js.md @@ -533,9 +533,10 @@ Contains details about an update that is available for download from the CodePus ###### Properties -The `RemotePackage` inherits all of the same properties as the `LocalPackage`, but includes one additional one: +The `RemotePackage` inherits all of the same properties as the `LocalPackage`, but includes the following additional ones: - __downloadUrl__: The URL at which the package is available for download. This property is only needed for advanced usage, since the `download` method will automatically handle the acquisition of updates for you. *(String)* +- __binaryPatchDownloadUrl__: The URL at which a binary patch archive of the package is available for download. It is only present when the release was published together with such a patch, and the package is downloaded in full from `downloadUrl` otherwise. *(String, optional)* ###### Methods