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/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/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 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'); + }, +}; 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 {