From 8c55d9905f0d580814d7ad41fa0b34e888483fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Wed, 29 Jul 2026 07:58:07 +0200 Subject: [PATCH] Vendor acquisition-sdk instead of depending on the code-push npm package Adds src/acquisition-sdk/ (vendored TypeScript from the archived microsoft/code-push repo, trimmed types.ts to only what's used, tests ported for future reference but not wired into any runner), a tsconfig.build.json + build:ts script compiling it to lib/ at prepack/setup time, and points CodePush.js at the compiled output instead of the code-push package. Drops the code-push dependency entirely, which was only ever used for this one submodule and pulled in the superagent/proxy-agent/vm2 CLI dependency chain that never actually shipped in the bundled app JS. --- .gitignore | 3 + .npmignore | 12 + CLAUDE.md | 6 +- CodePush.js | 2 +- package.json | 7 +- request-fetch-adapter.js | 4 +- .../__tests__/acquisition-rest-mock.ts | 124 ++++++++ .../__tests__/acquisition-sdk.test.ts | 301 ++++++++++++++++++ src/acquisition-sdk/acquisition-sdk.ts | 292 +++++++++++++++++ src/acquisition-sdk/code-push-error.ts | 36 +++ src/acquisition-sdk/types.ts | 46 +++ tsconfig.build.json | 24 ++ tsconfig.json | 9 +- 13 files changed, 858 insertions(+), 8 deletions(-) create mode 100644 src/acquisition-sdk/__tests__/acquisition-rest-mock.ts create mode 100644 src/acquisition-sdk/__tests__/acquisition-sdk.test.ts create mode 100644 src/acquisition-sdk/acquisition-sdk.ts create mode 100644 src/acquisition-sdk/code-push-error.ts create mode 100644 src/acquisition-sdk/types.ts create mode 100644 tsconfig.build.json diff --git a/.gitignore b/.gitignore index ddc58ec..5ee4900 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ build/Release # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git node_modules +# Compiled output of src/ (built via `npm run build:ts`, generated fresh at publish time) +lib/ + # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore diff --git a/.npmignore b/.npmignore index d2d8659..96444f3 100644 --- a/.npmignore +++ b/.npmignore @@ -34,6 +34,12 @@ Recipes/ bin/ test/ +# Don't publish TypeScript source - only the compiled lib/ output (built via `npm run build:ts` +# as part of `prepack`) ships +# Anchored to repo root (leading slash) - an unanchored `src/` also matches android/app/src/, +# which stripped the native Android module's Java sources from every published tarball. +/src/ + # Remove after this framework is published on NPM code-push-plugin-testing-framework/ @@ -101,3 +107,9 @@ packages/ .watchmanconfig +# Dev tooling, not relevant to consumers +.claude/ +.config/ +CLAUDE.md +mise.toml + diff --git a/CLAUDE.md b/CLAUDE.md index 1219509..6b1fda7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ React Native CodePush is a native module that enables over-the-air updates for R ### Build - `npm run build` - Build TypeScript tests to bin/ directory - `npm run tsc` - TypeScript compilation +- `npm run build:ts` - Compiles `src/` (currently just the vendored `acquisition-sdk`) to `lib/`, which is what ships to consumers instead of raw `.ts`. Wired into `setup` (so local dev/tests have `lib/` available) and `prepack` (so `npm publish`/`npm pack` always ship a freshly built `lib/`). + - This split (a separate `tsconfig.build.json` and `tsconfig.json` for `test/` -> `bin/`) is temporary. Once `CodePush.js` and the rest of this repo's runtime JS are migrated to TypeScript, these should be unified into a single build, and adopting `react-native-builder-bob` (or some other common tool) is worth considering at that point instead of hand-rolled `tsc` + npm script wiring. ### Platform Testing - Tests run on actual emulators/simulators with real React Native apps @@ -28,7 +30,7 @@ React Native CodePush is a native module that enables over-the-air updates for R - **JavaScript Bridge** (`CodePush.js`): Main API layer exposing update methods - **Native Modules**: Platform-specific implementations handling file operations, bundle management - **Update Manager**: Handles download, installation, and rollback logic -- **Acquisition SDK**: Manages server communication and update metadata +- **Acquisition SDK** (`src/acquisition-sdk/`): Manages server communication and update metadata ### Platform Structure - **iOS**: `ios/` - Objective-C implementation with CocoaPods integration @@ -46,6 +48,7 @@ React Native CodePush is a native module that enables over-the-air updates for R - **Custom Test Runner**: TypeScript-based test framework in `test/` - **Real App Testing**: Creates actual React Native apps for integration testing - **Scenario Testing**: Update, rollback, and error scenarios +- **No unit test infra yet**: this repo only has the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first. - **Templates**: `test/template/` holds native files (Podfile, AppDelegate, Android app files) and JS scenarios copied over top of a freshly generated RN/Expo app during test setup, overwriting its defaults — edit files here, not the generated project, for changes to persist - **`test:ios` vs `test:setup:ios` vs `test:fast:ios`**: `test:ios` is just `test:setup:ios` followed by `test:fast:ios` — the two are meant to be split apart for local iteration. - `test:setup:ios` (mocha `--ios --setup`) boots the simulator and provisions the test app once: copies templates, runs `pod install`, patches Info.plist/AppDelegate. It never builds or runs any test scenario. @@ -61,3 +64,4 @@ React Native CodePush is a native module that enables over-the-air updates for R - **Android Gradle Plugin**: Automatically generates bundle hashes and processes assets - **iOS CocoaPods**: Manages native dependencies and build configuration - **Bundle Processing**: Automated zip creation and hash calculation for OTA updates +- **`.npmignore` is a blocklist, not an allowlist**: `package.json` has no `files` field, so any new top-level file/dir ships to npm by default unless explicitly excluded. When adding new repo tooling/config, check whether it needs a `.npmignore` entry. Verify with `npm pack --dry-run`. diff --git a/CodePush.js b/CodePush.js index 2d1d9de..cb46ea9 100644 --- a/CodePush.js +++ b/CodePush.js @@ -1,4 +1,4 @@ -import { AcquisitionManager as Sdk } from "code-push/script/acquisition-sdk"; +import { AcquisitionManager as Sdk } from "./lib/acquisition-sdk/acquisition-sdk"; import { Alert } from "./AlertAdapter"; import requestFetchAdapter from "./request-fetch-adapter"; import { AppState, Platform } from "react-native"; diff --git a/package.json b/package.json index 7bab663..f15e2b1 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "author": "Bitrise", "license": "MIT", "scripts": { - "clean": "shx rm -rf bin", - "setup": "npm install --quiet --no-progress", + "clean": "shx rm -rf bin lib", + "setup": "npm install --quiet --no-progress && npm run build:ts", + "build:ts": "tsc -p tsconfig.build.json", + "prepack": "npm run build:ts", "prebuild:tests": "npm run clean && npm run tslint", "build:tests": "tsc", "test": "npm run build:tests && npm run test:setup && npm run test:fast", @@ -42,7 +44,6 @@ "url": "https://github.com/bitrise-io/react-native-code-push" }, "dependencies": { - "code-push": "4.2.3", "glob": "^7.1.7", "hoist-non-react-statics": "^3.3.2", "inquirer": "^8.1.5", diff --git a/request-fetch-adapter.js b/request-fetch-adapter.js index a3f2c46..b1d59b9 100644 --- a/request-fetch-adapter.js +++ b/request-fetch-adapter.js @@ -12,7 +12,7 @@ module.exports = { "Content-Type": "application/json", "X-CodePush-Plugin-Name": packageJson.name, "X-CodePush-Plugin-Version": packageJson.version, - "X-CodePush-SDK-Version": packageJson.dependencies["code-push"] + "X-CodePush-SDK-Version": packageJson.version }; if (requestBody && typeof requestBody === "object") { @@ -37,7 +37,7 @@ module.exports = { function getHttpMethodName(verb) { // Note: This should stay in sync with the enum definition in - // https://github.com/microsoft/code-push/blob/master/sdk/script/acquisition-sdk.ts#L6 + // https://github.com/microsoft/code-push/blob/master/src/script/acquisition-sdk.ts#L5 return [ "GET", "HEAD", diff --git a/src/acquisition-sdk/__tests__/acquisition-rest-mock.ts b/src/acquisition-sdk/__tests__/acquisition-rest-mock.ts new file mode 100644 index 0000000..4a67cbe --- /dev/null +++ b/src/acquisition-sdk/__tests__/acquisition-rest-mock.ts @@ -0,0 +1,124 @@ +// Ported from https://github.com/microsoft/code-push/blob/master/src/test/acquisition-rest-mock.ts (archived, MIT licensed) +// Not currently wired into any test runner — this repo has no unit test infra yet. + +import * as querystring from "querystring"; + +import * as acquisitionSdk from "../acquisition-sdk"; +import * as types from "../types"; + +export var validDeploymentKey = "Valid Deployment Key"; +export var latestPackage = { + download_url: "http://www.windowsazure.com/blobs/awperoiuqpweru", + description: "Angry flappy birds", + target_binary_range: "1.5.0", + label: "2.4.0", + is_mandatory: false, + is_available: true, + update_app_version: false, + package_hash: "hash240", + package_size: 1024 +}; + +export var serverUrl = "http://myurl.com"; +var publicPrefixUrl = "/v0.1/public/codepush"; +var reportStatusDeployUrl = serverUrl + publicPrefixUrl + "/report_status/deploy"; +var reportStatusDownloadUrl = serverUrl + publicPrefixUrl + "/report_status/download"; +var updateCheckUrl = serverUrl + publicPrefixUrl + "/update_check?"; + +export function updateMockUrl() { + reportStatusDeployUrl = serverUrl + publicPrefixUrl + "/report_status/deploy"; + reportStatusDownloadUrl = serverUrl + publicPrefixUrl + "/report_status/download"; + updateCheckUrl = serverUrl + publicPrefixUrl + "/update_check?"; +} + +export class HttpRequester implements acquisitionSdk.Http.Requester { + private expectedStatusCode: number; + + constructor(expectedStatusCode?: number) { + this.expectedStatusCode = expectedStatusCode; + } + + public request(verb: acquisitionSdk.Http.Verb, url: string, requestBodyOrCallback: string | acquisitionSdk.Callback, callback?: acquisitionSdk.Callback): void { + if (!callback && typeof requestBodyOrCallback === "function") { + callback = >requestBodyOrCallback; + } + + if (verb === acquisitionSdk.Http.Verb.GET && url.indexOf(updateCheckUrl) === 0) { + var params = querystring.parse(url.substring(updateCheckUrl.length)); + Server.onUpdateCheck(params, callback, this.expectedStatusCode); + } else if (verb === acquisitionSdk.Http.Verb.POST && url === reportStatusDeployUrl) { + Server.onReportStatus(callback, this.expectedStatusCode); + } else if (verb === acquisitionSdk.Http.Verb.POST && url === reportStatusDownloadUrl) { + Server.onReportStatus(callback, this.expectedStatusCode); + } else { + throw new Error("Unexpected call"); + } + } +} + +export class CustomResponseHttpRequester implements acquisitionSdk.Http.Requester { + response: acquisitionSdk.Http.Response; + + constructor(response: acquisitionSdk.Http.Response) { + this.response = response; + } + + public request(verb: acquisitionSdk.Http.Verb, url: string, requestBodyOrCallback: string | acquisitionSdk.Callback, callback?: acquisitionSdk.Callback): void { + if (typeof requestBodyOrCallback !== "function") { + throw new Error("Unexpected request body"); + } + + callback = >requestBodyOrCallback; + callback(null, this.response); + } +} + +class Server { + public static onAcquire(params: any, callback: acquisitionSdk.Callback): void { + if (params.deploymentKey !== validDeploymentKey) { + callback(/*error=*/ null, { + statusCode: 200, + body: JSON.stringify({ update_info: { isAvailable: false } }) + }); + } else { + callback(/*error=*/ null, { + statusCode: 200, + body: JSON.stringify({ update_info: latestPackage }) + }); + } + } + + public static onUpdateCheck(params: any, callback: acquisitionSdk.Callback, expectedStatusCode?: number): void { + var updateRequest: types.UpdateCheckRequest = { + deployment_key: params.deployment_key, + app_version: params.app_version, + package_hash: params.package_hash, + is_companion: !!(params.is_companion), + label: params.label + }; + + if (!updateRequest.deployment_key || !updateRequest.app_version) { + callback(/*error=*/ null, { statusCode: 400 }); + } else { + var updateInfo = { is_available: false }; + if (updateRequest.deployment_key === validDeploymentKey) { + if (updateRequest.is_companion || updateRequest.app_version === latestPackage.target_binary_range) { + if (updateRequest.package_hash !== latestPackage.package_hash) { + updateInfo = latestPackage; + } + } else if (updateRequest.app_version < latestPackage.target_binary_range) { + updateInfo = { update_app_version: true, target_binary_range: latestPackage.target_binary_range }; + } + } + + callback(/*error=*/ null, { + statusCode: expectedStatusCode ? expectedStatusCode : 200, + body: JSON.stringify({ update_info: updateInfo }) + }); + } + } + + public static onReportStatus(callback: acquisitionSdk.Callback, expectedStatusCode: number): void { + callback(/*error*/ null, /*response*/ { statusCode: expectedStatusCode ? expectedStatusCode : 200 }); + } +} diff --git a/src/acquisition-sdk/__tests__/acquisition-sdk.test.ts b/src/acquisition-sdk/__tests__/acquisition-sdk.test.ts new file mode 100644 index 0000000..70899c1 --- /dev/null +++ b/src/acquisition-sdk/__tests__/acquisition-sdk.test.ts @@ -0,0 +1,301 @@ +// Ported from https://github.com/microsoft/code-push/blob/master/src/test/acquisition-sdk.ts (archived, MIT licensed) +// Not currently wired into any test runner — this repo has no unit test infra yet. + +import * as assert from "assert"; + +import * as acquisitionSdk from "../acquisition-sdk"; +import * as acquisitionRestMock from "./acquisition-rest-mock"; +import * as types from "../types"; +import { CodePushPackageError } from "../code-push-error" +import { updateMockUrl } from "./acquisition-rest-mock"; + +const mockApi = acquisitionRestMock; +var latestPackage: types.UpdateCheckResponse = clone(mockApi.latestPackage); + +var configuration: acquisitionSdk.Configuration = { + appVersion: "1.5.0", + clientUniqueId: "My iPhone", + deploymentKey: mockApi.validDeploymentKey, + serverUrl: mockApi.serverUrl, +} + +var templateCurrentPackage: acquisitionSdk.Package = { + deploymentKey: mockApi.validDeploymentKey, + description: "Standard description", + label: "v1", + appVersion: latestPackage.target_binary_range, + packageHash: "hash001", + isMandatory: false, + packageSize: 100 +}; + +var scriptUpdateResult: acquisitionSdk.RemotePackage = { + deploymentKey: mockApi.validDeploymentKey, + description: latestPackage.description, + downloadUrl: latestPackage.download_url, + label: latestPackage.label, + appVersion: latestPackage.target_binary_range, + isMandatory: latestPackage.is_mandatory, + packageHash: latestPackage.package_hash, + packageSize: latestPackage.package_size +}; + +var nativeUpdateResult: acquisitionSdk.NativeUpdateNotification = { + updateAppVersion: true, + appVersion: latestPackage.target_binary_range +}; + +describe("Acquisition SDK", () => { + beforeEach(() => { + mockApi.latestPackage = clone(latestPackage); + mockApi.serverUrl = "http://myurl.com"; + updateMockUrl(); + }); + + it("Package with lower label and different package hash gives update", (done: Mocha.Done) => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + assert.deepEqual(scriptUpdateResult, returnPackage); + done(); + }); + }); + + it("Package with equal package hash gives no update", (done: Mocha.Done) => { + var equalVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + equalVersionPackage.packageHash = latestPackage.package_hash; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(equalVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + assert.equal(null, returnPackage); + done(); + }); + }); + + it("Package with higher different hash and higher label version gives update", (done: Mocha.Done) => { + var higherVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + higherVersionPackage.packageHash = "hash990"; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(higherVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + assert.deepEqual(scriptUpdateResult, returnPackage); + done(); + }); + }); + + it("Package with lower native version gives update notification", (done: Mocha.Done) => { + var lowerAppVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + assert.deepEqual(nativeUpdateResult, returnPackage); + done(); + }); + }); + + it("Package with higher native version gives no update", (done: Mocha.Done) => { + var higherAppVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + higherAppVersionPackage.appVersion = "9.9.0"; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(higherAppVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + assert.deepEqual(null, returnPackage); + done(); + }); + }); + + it("An empty response gives no update", (done: Mocha.Done) => { + var lowerAppVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + + var emptyResponse: acquisitionSdk.Http.Response = { + statusCode: 200, + body: JSON.stringify({}) + }; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(emptyResponse), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + done(); + }); + }); + + it("An unexpected (but valid) JSON response gives no update", (done: Mocha.Done) => { + var lowerAppVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + + var unexpectedResponse: acquisitionSdk.Http.Response = { + statusCode: 200, + body: JSON.stringify({ unexpected: "response" }) + }; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(unexpectedResponse), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + done(); + }); + }); + + it("Package for companion app ignores high native version and gives update", (done: Mocha.Done) => { + var higherAppVersionCompanionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + higherAppVersionCompanionPackage.appVersion = "9.9.0"; + + var companionAppConfiguration = clone(configuration); + configuration.ignoreAppVersion = true; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(higherAppVersionCompanionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.equal(null, error); + assert.deepEqual(scriptUpdateResult, returnPackage); + done(); + }); + }); + + it("If latest package is mandatory, returned package is mandatory", (done: Mocha.Done) => { + mockApi.latestPackage.is_mandatory = true; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage) => { + assert.equal(null, error); + assert.equal(true, returnPackage.isMandatory); + done(); + }); + }); + + it("If invalid arguments are provided, an error is raised", (done: Mocha.Done) => { + var invalidPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + invalidPackage.appVersion = null; + var expectedError = new CodePushPackageError("Calling common acquisition SDK with incorrect package") + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + try { + acquisition.queryUpdateWithCurrentPackage(invalidPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.fail("Should throw an error if the native implementation gave an incorrect package"); + done(); + }); + } catch (error) { + assert.deepEqual(error, expectedError); + assert.equal(error instanceof CodePushPackageError, true) + done(); + } + }); + + it("If an invalid JSON response is returned by the server, an error is raised", (done: Mocha.Done) => { + var lowerAppVersionPackage: acquisitionSdk.Package = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + + var invalidJsonResponse: acquisitionSdk.Http.Response = { + statusCode: 200, + body: "invalid {{ json" + }; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(invalidJsonResponse), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.notEqual(null, error); + done(); + }); + }); + + it("If deploymentKey is not valid...", (done: Mocha.Done) => { + // TODO: behavior is not defined + done(); + }); + + it("reportStatusDeploy(...) signals completion", (done: Mocha.Done): void => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + + acquisition.reportStatusDeploy(templateCurrentPackage, acquisitionSdk.AcquisitionStatus.DeploymentFailed, "1.5.0", mockApi.validDeploymentKey, ((error: Error, parameter: void): void => { + if (error) { + throw error; + } + + assert.equal(parameter, /*expected*/ null); + + done(); + })); + }); + + it("reportStatusDownload(...) signals completion", (done: Mocha.Done): void => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + + acquisition.reportStatusDownload(templateCurrentPackage, ((error: Error, parameter: void): void => { + if (error) { + throw error; + } + + assert.equal(parameter, /*expected*/ null); + + done(); + })); + }); + + it("disables api calls on unsuccessful response", (done: Mocha.Done): void => { + var invalidJsonResponse: acquisitionSdk.Http.Response = { + statusCode: 404, + body: "Not found" + }; + + mockApi.serverUrl = "https://codepush.appcenter.ms"; + updateMockUrl(); + configuration = { ...configuration, serverUrl: "https://codepush.appcenter.ms" }; + + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(invalidJsonResponse), configuration); + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.strictEqual((acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled, true); + (acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled = false; + }); + + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.strictEqual(returnPackage, null); + acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(404), configuration); + (acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled = false; + }); + + acquisition.reportStatusDeploy(templateCurrentPackage, acquisitionSdk.AcquisitionStatus.DeploymentSucceeded, "1.5.0", mockApi.validDeploymentKey, ((error: Error, parameter: void): void => { + assert.strictEqual((acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled, true); + acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(404), configuration); + (acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled = false; + })); + + acquisition.reportStatusDownload(templateCurrentPackage, ((error: Error, parameter: void): void => { + assert.strictEqual((acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled, true); + acquisition = acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(invalidJsonResponse), configuration); + (acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled = false; + })); + + done(); + }) + + it("doesn't disable api calls on successful response", (done: Mocha.Done): void => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + mockApi.serverUrl = "https://codepush.appcenter.ms"; + updateMockUrl(); + + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.strictEqual((acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled, false); + }); + + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error: Error, returnPackage: acquisitionSdk.RemotePackage | acquisitionSdk.NativeUpdateNotification) => { + assert.notStrictEqual(returnPackage, null); + }); + + acquisition.reportStatusDeploy(templateCurrentPackage, acquisitionSdk.AcquisitionStatus.DeploymentSucceeded, "1.5.0", mockApi.validDeploymentKey, ((error: Error, parameter: void): void => { + assert.strictEqual((acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled, false); + })); + + acquisition.reportStatusDownload(templateCurrentPackage, ((error: Error, parameter: void): void => { + assert.strictEqual((acquisitionSdk.AcquisitionManager as any)._apiCallsDisabled, false); + })); + + done(); + }) + +}); + +function clone(initialObject: T): T { + return JSON.parse(JSON.stringify(initialObject)); +} diff --git a/src/acquisition-sdk/acquisition-sdk.ts b/src/acquisition-sdk/acquisition-sdk.ts new file mode 100644 index 0000000..4d5c006 --- /dev/null +++ b/src/acquisition-sdk/acquisition-sdk.ts @@ -0,0 +1,292 @@ +// Vendored from https://github.com/microsoft/code-push/blob/master/src/script/acquisition-sdk.ts (archived, MIT licensed) + +import { UpdateCheckResponse, UpdateCheckRequest, DeploymentStatusReport, DownloadReport } from "./types"; +import { CodePushHttpError, CodePushDeployStatusError, CodePushPackageError } from "./code-push-error" + +export namespace Http { + export const enum Verb { + GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH + } + + export interface Response { + statusCode: number; + body?: string; + } + + export interface Requester { + request(verb: Verb, url: string, callback: Callback): void; + request(verb: Verb, url: string, requestBody: string, callback: Callback): void; + } +} + +// All fields are non-nullable, except when retrieving the currently running package on the first run of the app, +// in which case only the appVersion is compulsory +export interface Package { + deploymentKey: string; + description: string; + label: string; + appVersion: string; + isMandatory: boolean; + packageHash: string; + packageSize: number; +} + +export interface RemotePackage extends Package { + downloadUrl: string; +} + +export interface NativeUpdateNotification { + updateAppVersion: boolean; // Always true + appVersion: string; +} + +export interface LocalPackage extends Package { + localPath: string; +} + +export interface Callback { (error: Error, parameter: T): void; } + +export interface Configuration { + appVersion: string; + clientUniqueId: string; + deploymentKey: string; + serverUrl: string; + ignoreAppVersion?: boolean +} + +export class AcquisitionStatus { + public static DeploymentSucceeded = "DeploymentSucceeded"; + public static DeploymentFailed = "DeploymentFailed"; +} + +export class AcquisitionManager { + private readonly BASE_URL_PART = "appcenter.ms"; + private _appVersion: string; + private _clientUniqueId: string; + private _deploymentKey: string; + private _httpRequester: Http.Requester; + private _ignoreAppVersion: boolean; + private _serverUrl: string; + private _publicPrefixUrl: string = "v0.1/public/codepush/"; + private _statusCode: number; + private static _apiCallsDisabled: boolean = false; + constructor(httpRequester: Http.Requester, configuration: Configuration) { + this._httpRequester = httpRequester; + + this._serverUrl = configuration.serverUrl; + if (this._serverUrl.slice(-1) !== "/") { + this._serverUrl += "/"; + } + + this._appVersion = configuration.appVersion; + this._clientUniqueId = configuration.clientUniqueId; + this._deploymentKey = configuration.deploymentKey; + this._ignoreAppVersion = configuration.ignoreAppVersion; + } + + private isRecoverable = (statusCode: number): boolean => statusCode >= 500 || statusCode === 408 || statusCode === 429; + + private handleRequestFailure() { + if (this._serverUrl.includes(this.BASE_URL_PART) && !this.isRecoverable(this._statusCode)) { + AcquisitionManager._apiCallsDisabled = true; + } + } + + public queryUpdateWithCurrentPackage(currentPackage: Package, callback?: Callback): void { + if (AcquisitionManager._apiCallsDisabled) { + console.log(`[CodePush] Api calls are disabled, skipping API call`); + callback(/*error=*/ null, /*remotePackage=*/ null); + return; + } + + if (!currentPackage || !currentPackage.appVersion) { + throw new CodePushPackageError("Calling common acquisition SDK with incorrect package"); // Unexpected; indicates error in our implementation + } + + var updateRequest: UpdateCheckRequest = { + deployment_key: this._deploymentKey, + app_version: currentPackage.appVersion, + package_hash: currentPackage.packageHash, + is_companion: this._ignoreAppVersion, + label: currentPackage.label, + client_unique_id: this._clientUniqueId + }; + + var requestUrl: string = this._serverUrl + this._publicPrefixUrl + "update_check?" + queryStringify(updateRequest); + + this._httpRequester.request(Http.Verb.GET, requestUrl, (error: Error, response: Http.Response) => { + if (error) { + callback(error, /*remotePackage=*/ null); + return; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + let errorMessage: any; + this._statusCode = response.statusCode; + this.handleRequestFailure(); + if (response.statusCode === 0) { + errorMessage = `Couldn't send request to ${requestUrl}, xhr.statusCode = 0 was returned. One of the possible reasons for that might be connection problems. Please, check your internet connection.`; + } else { + errorMessage = `${response.statusCode}: ${response.body}`; + } + callback(new CodePushHttpError(errorMessage), /*remotePackage=*/ null); + return; + } + try { + var responseObject = JSON.parse(response.body); + var updateInfo: UpdateCheckResponse = responseObject.update_info; + } catch (error) { + callback(error, /*remotePackage=*/ null); + return; + } + + if (!updateInfo) { + callback(error, /*remotePackage=*/ null); + return; + } else if (updateInfo.update_app_version) { + callback(/*error=*/ null, { updateAppVersion: true, appVersion: updateInfo.target_binary_range }); + return; + } else if (!updateInfo.is_available) { + callback(/*error=*/ null, /*remotePackage=*/ null); + return; + } + + var remotePackage: RemotePackage = { + deploymentKey: this._deploymentKey, + description: updateInfo.description, + label: updateInfo.label, + appVersion: updateInfo.target_binary_range, + isMandatory: updateInfo.is_mandatory, + packageHash: updateInfo.package_hash, + packageSize: updateInfo.package_size, + downloadUrl: updateInfo.download_url + }; + + callback(/*error=*/ null, remotePackage); + }); + } + + public reportStatusDeploy(deployedPackage?: Package, status?: string, previousLabelOrAppVersion?: string, previousDeploymentKey?: string, callback?: Callback): void { + if (AcquisitionManager._apiCallsDisabled) { + console.log(`[CodePush] Api calls are disabled, skipping API call`); + callback(/*error*/ null, /*not used*/ null); + return; + } + + var url: string = this._serverUrl + this._publicPrefixUrl + "report_status/deploy"; + var body: DeploymentStatusReport = { + app_version: this._appVersion, + deployment_key: this._deploymentKey + }; + + if (this._clientUniqueId) { + body.client_unique_id = this._clientUniqueId; + } + + if (deployedPackage) { + body.label = deployedPackage.label; + body.app_version = deployedPackage.appVersion; + + switch (status) { + case AcquisitionStatus.DeploymentSucceeded: + case AcquisitionStatus.DeploymentFailed: + body.status = status; + break; + + default: + if (callback) { + if (!status) { + callback(new CodePushDeployStatusError("Missing status argument."), /*not used*/ null); + } else { + callback(new CodePushDeployStatusError("Unrecognized status \"" + status + "\"."), /*not used*/ null); + } + } + return; + } + } + + if (previousLabelOrAppVersion) { + body.previous_label_or_app_version = previousLabelOrAppVersion; + } + + if (previousDeploymentKey) { + body.previous_deployment_key = previousDeploymentKey; + } + + callback = typeof arguments[arguments.length - 1] === "function" && arguments[arguments.length - 1]; + + this._httpRequester.request(Http.Verb.POST, url, JSON.stringify(body), (error: Error, response: Http.Response): void => { + if (callback) { + if (error) { + callback(error, /*not used*/ null); + return; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + this._statusCode = response.statusCode; + this.handleRequestFailure(); + callback(new CodePushHttpError(response.statusCode + ": " + response.body), /*not used*/ null); + return; + } + + callback(/*error*/ null, /*not used*/ null); + } + }); + } + + public reportStatusDownload(downloadedPackage: Package, callback?: Callback): void { + if (AcquisitionManager._apiCallsDisabled) { + console.log(`[CodePush] Api calls are disabled, skipping API call`); + callback(/*error*/ null, /*not used*/ null); + return; + } + + var url: string = this._serverUrl + this._publicPrefixUrl + "report_status/download"; + var body: DownloadReport = { + client_unique_id: this._clientUniqueId, + deployment_key: this._deploymentKey, + label: downloadedPackage.label + }; + + this._httpRequester.request(Http.Verb.POST, url, JSON.stringify(body), (error: Error, response: Http.Response): void => { + if (callback) { + if (error) { + callback(error, /*not used*/ null); + return; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + this._statusCode = response.statusCode; + this.handleRequestFailure(); + callback(new CodePushHttpError(response.statusCode + ": " + response.body), /*not used*/ null); + return; + } + + callback(/*error*/ null, /*not used*/ null); + } + }); + } +} + +function queryStringify(object: Object): string { + var queryString = ""; + var isFirst: boolean = true; + + for (var property in object) { + if (object.hasOwnProperty(property)) { + var value: string = (object)[property]; + if (value !== null && typeof value !== "undefined") { + if (!isFirst) { + queryString += "&"; + } + + queryString += encodeURIComponent(property) + "="; + queryString += encodeURIComponent(value); + } + + isFirst = false; + } + } + + return queryString; +} \ No newline at end of file diff --git a/src/acquisition-sdk/code-push-error.ts b/src/acquisition-sdk/code-push-error.ts new file mode 100644 index 0000000..ab7f192 --- /dev/null +++ b/src/acquisition-sdk/code-push-error.ts @@ -0,0 +1,36 @@ +// Vendored from https://github.com/microsoft/code-push/blob/master/src/script/code-push-error.ts (archived, MIT licensed) + +export class CodePushError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, CodePushError.prototype); + } +} + +export class CodePushHttpError extends CodePushError { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, CodePushHttpError.prototype); + } +} + +export class CodePushDeployStatusError extends CodePushError { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, CodePushDeployStatusError.prototype); + } +} + +export class CodePushPackageError extends CodePushError { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, CodePushPackageError.prototype); + } +} + +export class CodePushUnauthorizedError extends CodePushError { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, CodePushUnauthorizedError.prototype); + } +} diff --git a/src/acquisition-sdk/types.ts b/src/acquisition-sdk/types.ts new file mode 100644 index 0000000..1ba2a79 --- /dev/null +++ b/src/acquisition-sdk/types.ts @@ -0,0 +1,46 @@ +// Trimmed from https://github.com/microsoft/code-push/blob/master/src/script/types.ts (archived, MIT licensed) +// Upstream's types.ts also contains management/CLI-API types (Account, App, Deployment, ...) +// that are irrelevant to the on-device acquisition client + +/*in*/ +export interface DeploymentStatusReport { + app_version: string; + client_unique_id?: string; + deployment_key: string; + previous_deployment_key?: string; + previous_label_or_app_version?: string; + label?: string; + status?: string; +} + +/*in*/ +export interface DownloadReport { + client_unique_id: string; + deployment_key: string; + label: string; +} + +/*out*/ +export interface UpdateCheckResponse { + download_url?: string; + description?: string; + is_available: boolean; + is_disabled?: boolean; + target_binary_range: string; + /*generated*/ label?: string; + /*generated*/ package_hash?: string; + package_size?: number; + should_run_binary_version?: boolean; + update_app_version?: boolean; + is_mandatory?: boolean; +} + +/*in*/ +export interface UpdateCheckRequest { + app_version: string; + client_unique_id?: string; + deployment_key: string; + is_companion?: boolean; + label?: string; + package_hash?: string; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c5458d1 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,24 @@ +// Compiles src/ -> lib/: the actual runtime code this package ships to consumers. Run via `npm run build:ts`. + +// This is a separate config from tsconfig.json because that one's rootDir/outDir (test -> bin) +// and target audience (the mocha test suite) are unrelated to what we publish to consumers. +// +// TODO: once the rest of this repo's runtime JS (CodePush.js, etc.) is migrated to TypeScript, +// this and tsconfig.json should be unified into a single project. +{ + "compilerOptions": { + "target": "ES5", + "module": "commonjs", + "lib": ["es6"], + "declaration": true, + "noImplicitAny": true, + "noEmitOnError": true, + "moduleResolution": "node", + "sourceMap": true, + "rootDir": "src", + "outDir": "lib", + "removeComments": false + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/tsconfig.json b/tsconfig.json index cfb67da..4fad394 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,9 @@ +// Compiles test/ -> bin/ for the mocha-based integration test suite (see package.json's +// build:tests/test:* scripts). Deliberately excludes src/ - that's a separate compilation unit +// with its own config, see tsconfig.build.json for why. +// +// TODO: once the rest of this repo's runtime JS (CodePush.js, etc.) is migrated to TypeScript, +// this and tsconfig.build.json should be unified into a single project. { "compilerOptions": { "target": "ES5", @@ -12,6 +18,7 @@ "removeComments": true }, "exclude": [ - "Examples" + "Examples", + "src" ] }