From 75046b0b35846da36290d968cf25a62c6cb64c12 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Fri, 6 Sep 2024 13:39:44 +0100 Subject: [PATCH] feat(eas-cli): Implement initial `worker:deploy` command (#2447) * Update generated schema * Add initial structure for worker code * Add generated types to mutation * Add initial command implementation * Fix linting * [ENG-12738] prompt for dev domain name (#2452) * Add a dist folder check * Prompt adding dev domain name on the CLI * Validate for dev domain name taken * Add progress indicator and 413 error message * feat: Implement new upload process with new `deployment-api` endpoints (#2487) * Add utilities for new worker upload process * Add updated upload method * Add server/client folder errors * Implement new upload process * Delete code for old upload process * Fix fetch helper obfuscating errors * Add server text to error * Temporarily disable compression * Apply lints * refactor(worker): Implement batched uploads and improved progress output (#2490) * Add refactored upload and batched upload utilities * Switch to batch uploading utilities * Add console output * Apply lints * Fix typo * Fix error output * Ignore system files while uploading (#2492) * Mark worker:deploy as hidden command * Replace ts-ignore directive comments * Add changelog entry * Apply suggestions from code review Co-authored-by: Szymon Dziedzic Co-authored-by: Cedric van Putten * Switch to dynamic project config * Remove superfluous formatSourcemap code * Add async suffix * Add max file-size note * Normalise to throws * Apply lints * Log out eas url after deploy * feat(eas-cli): Add support for static worker deployments (#2536) * Add fallback logic for static projects * Read project config for web.output mode * Update GraphQL codegen'd files --------- Co-authored-by: Kadi Kraman Co-authored-by: Szymon Dziedzic Co-authored-by: Cedric van Putten --- CHANGELOG.md | 2 +- packages/eas-cli/graphql-codegen.yml | 1 + packages/eas-cli/graphql.schema.json | 1622 +++++++++++++++-- packages/eas-cli/package.json | 3 + .../eas-cli/src/commands/worker/deploy.ts | 212 +++ packages/eas-cli/src/graphql/generated.ts | 228 ++- packages/eas-cli/src/worker/assets.ts | 149 ++ packages/eas-cli/src/worker/deployment.ts | 89 + packages/eas-cli/src/worker/mutations.ts | 71 + packages/eas-cli/src/worker/upload.ts | 177 ++ yarn.lock | 113 ++ 11 files changed, 2485 insertions(+), 182 deletions(-) create mode 100644 packages/eas-cli/src/commands/worker/deploy.ts create mode 100644 packages/eas-cli/src/worker/assets.ts create mode 100644 packages/eas-cli/src/worker/deployment.ts create mode 100644 packages/eas-cli/src/worker/mutations.ts create mode 100644 packages/eas-cli/src/worker/upload.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e8770674..642eaf85ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- **Internal/Experimental:** Add EAS Worker command ([#2447](https://github.com/expo/eas-cli/pull/2447) by [@kitten](https://github.com/kitten)) - Upload fingeprint source as part of eas update command. ([#2533](https://github.com/expo/eas-cli/pull/2533) by [@wschurman](https://github.com/wschurman)) ### 🐛 Bug fixes @@ -24,7 +25,6 @@ This is the log of notable changes to EAS CLI and related packages. - Add support for syncing Journaling Suggestions, Managed App Installation UI, and 5G Network Slicing capabilities. ([#2525](https://github.com/expo/eas-cli/pull/2525) by [@szdziedzic](https://github.com/szdziedzic)) - ## [11.0.3](https://github.com/expo/eas-cli/releases/tag/v11.0.3) - 2024-08-31 ### 🐛 Bug fixes diff --git a/packages/eas-cli/graphql-codegen.yml b/packages/eas-cli/graphql-codegen.yml index 373266f4cb..69e7315c47 100644 --- a/packages/eas-cli/graphql-codegen.yml +++ b/packages/eas-cli/graphql-codegen.yml @@ -7,6 +7,7 @@ documents: - 'src/commands/**/*.ts' - 'src/branch/**/*.ts' - 'src/channel/**/*.ts' + - 'src/worker/**/*.ts' generates: src/graphql/generated.ts: plugins: diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 2e567a8b76..4b7e85d7a0 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -10636,6 +10636,18 @@ "name": "workerDeploymentsMetrics", "description": null, "args": [ + { + "name": "filters", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "MetricsFilters", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "timespan", "description": null, @@ -17461,6 +17473,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "BranchQuery", + "description": null, + "fields": [ + { + "name": "byId", + "description": "Query a Branch by ID", + "args": [ + { + "name": "branchId", + "description": "Branch ID to use to look up branch", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UpdateBranch", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Build", @@ -17699,6 +17755,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "deployment", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Deployment", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "developmentClient", "description": null, @@ -18216,6 +18284,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "runtime", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Runtime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "runtimeVersion", "description": null, @@ -18225,8 +18305,8 @@ "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "Use 'runtime' field ." }, { "name": "sdkVersion", @@ -18879,8 +18959,8 @@ "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "Use 'runtime.fingerprintDebugInfoUrl' instead." }, { "name": "xcodeBuildLogsUrl", @@ -21479,6 +21559,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "ChannelQuery", + "description": null, + "fields": [ + { + "name": "byId", + "description": "Query a Channel by ID", + "args": [ + { + "name": "channelId", + "description": "Channel ID to use to look up channel", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UpdateChannel", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Charge", @@ -22976,30 +23100,6 @@ }, "isDeprecated": false, "deprecationReason": null - }, - { - "name": "mostPopularUpdates", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Update", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null } ], "inputFields": null, @@ -24416,6 +24516,81 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeploymentCumulativeMetricsOverTimeData", + "description": null, + "fields": [ + { + "name": "data", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "LineChartData", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metricsAtLastTimestamp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "LineDatapoint", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mostPopularUpdates", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Update", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "DeploymentEdge", @@ -24525,7 +24700,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "CumulativeMetricsOverTimeData", + "name": "DeploymentCumulativeMetricsOverTimeData", "ofType": null } }, @@ -24694,6 +24869,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeploymentQuery", + "description": null, + "fields": [ + { + "name": "byId", + "description": "Query a Deployment by ID", + "args": [ + { + "name": "deploymentId", + "description": "Deployment ID to use to look up deployment", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Deployment", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "DeploymentSignedUrlResult", @@ -34159,72 +34378,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "deleteAccount", - "description": "Delete an Account created via createAccount", - "args": [ - { - "name": "accountId", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "DeleteAccountResult", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deleteSSOUser", - "description": "Delete a SSO user. Actor must be an owner on the SSO user's SSO account.", - "args": [ - { - "name": "ssoUserId", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "DeleteSSOUserResult", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "deleteSecondFactorDevice", "description": "Delete a second factor device", @@ -34841,6 +34994,308 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "MetricsCacheStatus", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "HIT", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MISS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PASS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MetricsFilters", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "cacheStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MetricsCacheStatus", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "continent", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ContinentCode", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasCustomDomainOrigin", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isAsset", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCrash", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isVerifiedBot", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "method", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MetricsRequestMethod", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "os", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "UserAgentOS", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statusType", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MetricsStatusType", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MetricsRequestMethod", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "DELETE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GET", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OPTIONS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "POST", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PUT", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MetricsStatusType", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "CLIENT_ERROR", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NONE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "REDIRECT", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SERVER_ERROR", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUCCESSFUL", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "MetricsTimespan", @@ -37173,39 +37628,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "deleteRobot", - "description": "Delete a Robot", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "DeleteRobotResult", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "scheduleRobotDeletion", "description": "Schedule deletion of a Robot", @@ -38532,6 +38954,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "branches", + "description": "Top-level query object for querying Branchs.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BranchQuery", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "buildAnnotations", "description": "Top-level query object for querying annotations.", @@ -38580,6 +39018,38 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "channels", + "description": "Top-level query object for querying Channels.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChannelQuery", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deployments", + "description": "Top-level query object for querying Deployments.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeploymentQuery", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "experimentation", "description": "Top-level query object for querying Experimentation configuration.", @@ -38696,6 +39166,22 @@ "isDeprecated": true, "deprecationReason": "Snacks and apps should be queried separately" }, + { + "name": "runtimes", + "description": "Top-level query object for querying Runtimes.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RuntimeQuery", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "snack", "description": null, @@ -39126,6 +39612,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "RuntimeQuery", + "description": null, + "fields": [ + { + "name": "byId", + "description": "Query a Runtime by ID", + "args": [ + { + "name": "runtimeId", + "description": "Runtime ID to use to look up runtime", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Runtime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "RuntimesConnection", @@ -44382,6 +44912,39 @@ "name": "UpdateInsights", "description": null, "fields": [ + { + "name": "cumulativeMetricsOverTime", + "description": null, + "args": [ + { + "name": "timespan", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "InsightsTimespan", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CumulativeMetricsOverTimeData", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": null, @@ -49393,6 +49956,18 @@ "name": "metrics", "description": null, "args": [ + { + "name": "filters", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "MetricsFilters", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "timespan", "description": null, @@ -50225,7 +50800,7 @@ "description": null, "fields": [ { - "name": "groups", + "name": "byBrowser", "description": null, "args": [], "type": { @@ -50235,9 +50810,13 @@ "kind": "LIST", "name": null, "ofType": { - "kind": "OBJECT", - "name": "WorkerDeploymentMetricsEdge", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsBrowserEdge", + "ofType": null + } } } }, @@ -50245,16 +50824,48 @@ "deprecationReason": null }, { - "name": "id", + "name": "byContinent", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsContinentEdge", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "byOS", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsOperatingSystemEdge", + "ofType": null + } + } } }, "isDeprecated": false, @@ -50285,12 +50896,36 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "WorkerDeploymentMetricsData", + "name": "WorkerDeploymentMetricsNode", "ofType": null } }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "timeseries", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsTimeseriesEdge", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -50300,23 +50935,185 @@ }, { "kind": "OBJECT", - "name": "WorkerDeploymentMetricsData", + "name": "WorkerDeploymentMetricsBrowserEdge", "description": null, "fields": [ { - "name": "crashesSum", + "name": "browser", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "UserAgentBrowser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsNode", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsContinentEdge", + "description": null, + "fields": [ + { + "name": "continent", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ContinentCode", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsNode", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsNode", + "description": null, + "fields": [ + { + "name": "assetsPerMs", "description": null, "args": [], "type": { "kind": "SCALAR", - "name": "Int", + "name": "Float", "ofType": null }, "isDeprecated": false, "deprecationReason": null }, { - "name": "durationP50", + "name": "assetsSum", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cacheHitRatio", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cacheHitRatioP50", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cacheHitRatioP90", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cacheHitRatioP99", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cacheHitsPerMs", "description": null, "args": [], "type": { @@ -50328,7 +51125,215 @@ "deprecationReason": null }, { - "name": "durationP90", + "name": "cacheHitsSum", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cachePassRatio", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cachePassRatioP50", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cachePassRatioP90", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cachePassRatioP99", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientErrorRatio", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientErrorRatioP50", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientErrorRatioP90", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientErrorRatioP99", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crashRatio", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crashRatioP50", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crashRatioP90", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crashRatioP99", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crashesPerMs", "description": null, "args": [], "type": { @@ -50339,10 +51344,90 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "crashesSum", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "duration", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "durationP50", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "durationP90", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "durationP99", "description": null, "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestsPerMs", + "description": null, + "args": [], "type": { "kind": "SCALAR", "name": "Float", @@ -50355,13 +51440,153 @@ "name": "requestsSum", "description": null, "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sampleRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "serverErrorRatio", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "serverErrorRatioP50", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "serverErrorRatioP90", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "serverErrorRatioP99", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "staleIfErrorPerMs", + "description": null, + "args": [], "type": { "kind": "SCALAR", - "name": "Int", + "name": "Float", "ofType": null }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "staleIfErrorSum", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "staleWhileRevalidatePerMs", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "staleWhileRevalidateSum", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -50371,7 +51596,7 @@ }, { "kind": "OBJECT", - "name": "WorkerDeploymentMetricsEdge", + "name": "WorkerDeploymentMetricsOperatingSystemEdge", "description": null, "fields": [ { @@ -50383,13 +51608,120 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "WorkerDeploymentMetricsData", + "name": "WorkerDeploymentMetricsNode", "ofType": null } }, "isDeprecated": false, "deprecationReason": null }, + { + "name": "os", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "UserAgentOS", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsTimeseriesEdge", + "description": null, + "fields": [ + { + "name": "byBrowser", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsBrowserEdge", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "byContinent", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsContinentEdge", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "byOS", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsOperatingSystemEdge", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "WorkerDeploymentMetricsNode", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "timestamp", "description": null, diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 692d7bb8a5..e1de79c7a2 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -63,6 +63,7 @@ "log-symbols": "4.1.0", "mime": "3.0.0", "minimatch": "5.1.2", + "minizlib": "3.0.1", "nanoid": "3.3.4", "node-fetch": "2.6.7", "node-forge": "1.3.1", @@ -78,6 +79,7 @@ "semver": "7.5.4", "slash": "3.0.0", "tar": "6.2.1", + "tar-stream": "3.1.7", "terminal-link": "2.1.1", "tslib": "2.6.2", "turndown": "7.1.2", @@ -105,6 +107,7 @@ "@types/prompts": "2.4.2", "@types/semver": "7.5.6", "@types/tar": "6.1.10", + "@types/tar-stream": "3.1.3", "@types/tough-cookie": "4.0.2", "@types/uuid": "9.0.7", "@types/wrap-ansi": "3.0.0", diff --git a/packages/eas-cli/src/commands/worker/deploy.ts b/packages/eas-cli/src/commands/worker/deploy.ts new file mode 100644 index 0000000000..c7ce4af3f4 --- /dev/null +++ b/packages/eas-cli/src/commands/worker/deploy.ts @@ -0,0 +1,212 @@ +import chalk from 'chalk'; +import fs from 'node:fs'; +import * as path from 'node:path'; + +import EasCommand from '../../commandUtils/EasCommand'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { createProgressTracker } from '../../utils/progress'; +import * as WorkerAssets from '../../worker/assets'; +import { getSignedDeploymentUrlAsync } from '../../worker/deployment'; +import { UploadParams, batchUploadAsync, uploadAsync } from '../../worker/upload'; + +const isDirectory = (directoryPath: string): Promise => + fs.promises + .stat(directoryPath) + .then(stat => stat.isDirectory()) + .catch(() => false); + +export default class WorkerDeploy extends EasCommand { + static override description = 'deploy an Expo web build'; + static override aliases = ['deploy']; + + // TODO(@kitten): Keep command hidden until worker deployments are live + static override hidden = true; + static override state = 'beta'; + + static override flags = { + // TODO(@kitten): Allow deployment identifier to be specified + }; + + static override contextDefinition = { + ...this.ContextOptions.DynamicProjectConfig, + ...this.ContextOptions.ProjectDir, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + Log.warn('EAS Worker Deployments are in beta and subject to breaking changes.'); + + const { + getDynamicPrivateProjectConfigAsync, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(WorkerDeploy, { + nonInteractive: true, + }); + + const { projectId, projectDir, exp } = await getDynamicPrivateProjectConfigAsync(); + const distPath = path.resolve(projectDir, 'dist'); + + let distServerPath: string | null; + let distClientPath: string; + if (exp.web?.output === 'static') { + distClientPath = distPath; + distServerPath = null; + if (!(await isDirectory(distClientPath))) { + throw new Error( + `No "dist/" folder found. Prepare your project for deployment with "npx expo export"` + ); + } + Log.log('Detected "static" worker deployment'); + } else if (exp.web?.output === 'server') { + distClientPath = path.resolve(distPath, 'client'); + distServerPath = path.resolve(distPath, 'server'); + if (!(await isDirectory(distClientPath))) { + throw new Error( + `No "dist/client/" folder found. Prepare your project for deployment with "npx expo export"` + ); + } else if (!(await isDirectory(distServerPath))) { + throw new Error( + `No "dist/server/" folder found. Prepare your project for deployment with "npx expo export"` + ); + } + Log.log('Detected "server" worker deployment'); + } else { + throw new Error( + `Single-page apps are not supported. Ensure that app.json key "expo.web.output" is set to "server" or "static".` + ); + } + + async function* emitWorkerTarballAsync( + assetMap: WorkerAssets.AssetMap + ): AsyncGenerator { + yield ['assets.json', JSON.stringify(assetMap)]; + + // TODO: Create manifest from user configuration + const manifest = { env: {} }; + yield ['manifest.json', JSON.stringify(manifest)]; + + if (distServerPath) { + const workerFiles = WorkerAssets.listWorkerFilesAsync(distServerPath); + for await (const workerFile of workerFiles) { + yield [`server/${workerFile.normalizedPath}`, workerFile.data]; + } + } + } + + async function uploadTarballAsync(tarPath: string): Promise { + const uploadUrl = await getSignedDeploymentUrlAsync(graphqlClient, exp, { + appId: projectId, + }); + + const { response } = await uploadAsync({ + url: uploadUrl, + filePath: tarPath, + compress: false, + headers: { + accept: 'application/json', + }, + }); + if (response.status === 413) { + throw new Error( + 'Upload failed! (Payload too large)\n' + + `The files in "dist/server/" (at: ${distServerPath}) exceed the maximum file size (10MB gzip).` + ); + } else if (!response.ok) { + throw new Error(`Upload failed! (${response.statusText})`); + } else { + const json = await response.json(); + if (!json.success || !json.result || typeof json.result !== 'object') { + throw new Error(json.message ? `Upload failed: ${json.message}` : 'Upload failed!'); + } + return json.result; + } + } + + async function uploadAssetsAsync( + assetMap: WorkerAssets.AssetMap, + uploads: Record + ): Promise { + if (typeof uploads !== 'object' || !uploads) { + return; + } + + // TODO(@kitten): Batch and upload multiple files in parallel + const uploadParams: UploadParams[] = []; + for await (const asset of WorkerAssets.listAssetMapFilesAsync(distClientPath, assetMap)) { + const uploadURL = uploads[asset.normalizedPath]; + if (uploadURL) { + uploadParams.push({ url: uploadURL, filePath: asset.path }); + } + } + + const progress = { + total: uploadParams.length, + pending: 0, + percent: 0, + transferred: 0, + }; + + const updateProgress = createProgressTracker({ + total: progress.total, + message(ratio) { + const percent = `${Math.floor(ratio * 100)}`; + const details = chalk.dim( + `(${progress.pending} Pending, ${progress.transferred} Completed, ${progress.total} Total)` + ); + return `Uploading client assets: ${percent.padStart(3)}% ${details}`; + }, + completedMessage: 'Uploaded assets for serverless deployment', + }); + + try { + for await (const signal of batchUploadAsync(uploadParams)) { + if ('response' in signal) { + progress.pending--; + progress.percent = ++progress.transferred / progress.total; + } else { + progress.pending++; + } + updateProgress({ progress }); + } + } catch (error: any) { + updateProgress({ isComplete: true, error }); + throw error; + } + updateProgress({ isComplete: true }); + } + + let progress = ora('Preparing worker upload'); + let assetMap: WorkerAssets.AssetMap; + let tarPath: string; + try { + assetMap = await WorkerAssets.createAssetMapAsync(distClientPath); + tarPath = await WorkerAssets.packFilesIterableAsync(emitWorkerTarballAsync(assetMap)); + } catch (error: any) { + progress.fail('Failed to prepare worker upload'); + throw error; + } + progress.succeed('Prepared worker upload'); + + progress = ora('Creating worker deployment'); + let deployResult: any; + try { + deployResult = await uploadTarballAsync(tarPath); + } catch (error: any) { + progress.fail('Failed to create worker deployment'); + throw error; + } + progress.succeed('Created worker deployment'); + + await uploadAssetsAsync(assetMap, deployResult.uploads); + + const baseDomain = process.env.EXPO_STAGING ? 'staging.expo' : 'expo'; + const deploymentURL = `https://${deployResult.fullName}.${baseDomain}.app`; + const deploymentsUrl = `https://${baseDomain}.dev/accounts/${exp.owner}/projects/${deployResult.name}/serverless/deployments`; + + Log.addNewLineIfNone(); + Log.log(`🎉 Your worker deployment is ready: ${deploymentURL}`); + Log.addNewLineIfNone(); + Log.log(`🔗 Manage on EAS: ${deploymentsUrl}`); + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 29dc2cb2bc..9832f725e5 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -1639,6 +1639,7 @@ export type AppWorkerDeploymentsCrashesArgs = { /** Represents an Exponent App (or Experience in legacy terms) */ export type AppWorkerDeploymentsMetricsArgs = { + filters?: InputMaybe; timespan: MetricsTimespan; }; @@ -2617,6 +2618,17 @@ export type BranchFilterInput = { searchTerm?: InputMaybe; }; +export type BranchQuery = { + __typename?: 'BranchQuery'; + /** Query a Branch by ID */ + byId: UpdateBranch; +}; + + +export type BranchQueryByIdArgs = { + branchId: Scalars['ID']['input']; +}; + /** Represents an EAS Build */ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & { __typename?: 'Build'; @@ -2637,6 +2649,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & { createdAt: Scalars['DateTime']['output']; customNodeVersion?: Maybe; customWorkflowName?: Maybe; + deployment?: Maybe; developmentClient?: Maybe; distribution?: Maybe; enqueuedAt?: Maybe; @@ -2685,6 +2698,8 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & { resourceClassDisplayName: Scalars['String']['output']; retryDisabledReason?: Maybe; runFromCI?: Maybe; + runtime?: Maybe; + /** @deprecated Use 'runtime' field . */ runtimeVersion?: Maybe; sdkVersion?: Maybe; selectedImage?: Maybe; @@ -2782,6 +2797,7 @@ export type BuildArtifacts = { applicationArchiveUrl?: Maybe; buildArtifactsUrl?: Maybe; buildUrl?: Maybe; + /** @deprecated Use 'runtime.fingerprintDebugInfoUrl' instead. */ fingerprintUrl?: Maybe; xcodeBuildLogsUrl?: Maybe; }; @@ -3173,6 +3189,17 @@ export type ChannelFilterInput = { searchTerm?: InputMaybe; }; +export type ChannelQuery = { + __typename?: 'ChannelQuery'; + /** Query a Channel by ID */ + byId: UpdateChannel; +}; + + +export type ChannelQueryByIdArgs = { + channelId: Scalars['ID']['input']; +}; + export type Charge = { __typename?: 'Charge'; amount: Scalars['Int']['output']; @@ -3346,7 +3373,6 @@ export type CumulativeMetricsOverTimeData = { __typename?: 'CumulativeMetricsOverTimeData'; data: LineChartData; metricsAtLastTimestamp: Array; - mostPopularUpdates: Array; }; export type CustomBuildConfigInput = { @@ -3576,6 +3602,13 @@ export type DeploymentBuildsConnection = { pageInfo: PageInfo; }; +export type DeploymentCumulativeMetricsOverTimeData = { + __typename?: 'DeploymentCumulativeMetricsOverTimeData'; + data: LineChartData; + metricsAtLastTimestamp: Array; + mostPopularUpdates: Array; +}; + export type DeploymentEdge = { __typename?: 'DeploymentEdge'; cursor: Scalars['String']['output']; @@ -3589,7 +3622,7 @@ export type DeploymentFilterInput = { export type DeploymentInsights = { __typename?: 'DeploymentInsights'; - cumulativeMetricsOverTime: CumulativeMetricsOverTimeData; + cumulativeMetricsOverTime: DeploymentCumulativeMetricsOverTimeData; embeddedUpdateTotalUniqueUsers: Scalars['Int']['output']; embeddedUpdateUniqueUsersOverTime: UniqueUsersOverTimeData; id: Scalars['ID']['output']; @@ -3622,6 +3655,17 @@ export type DeploymentInsightsUniqueUsersOverTimeArgs = { timespan: InsightsTimespan; }; +export type DeploymentQuery = { + __typename?: 'DeploymentQuery'; + /** Query a Deployment by ID */ + byId: Deployment; +}; + + +export type DeploymentQueryByIdArgs = { + deploymentId: Scalars['ID']['input']; +}; + export type DeploymentSignedUrlResult = { __typename?: 'DeploymentSignedUrlResult'; deploymentIdentifier: Scalars['ID']['output']; @@ -4921,10 +4965,6 @@ export type MeMutation = { certifySecondFactorDevice: SecondFactorBooleanResult; /** Create a new Account and grant this User the owner Role */ createAccount: Account; - /** Delete an Account created via createAccount */ - deleteAccount: DeleteAccountResult; - /** Delete a SSO user. Actor must be an owner on the SSO user's SSO account. */ - deleteSSOUser: DeleteSsoUserResult; /** Delete a second factor device */ deleteSecondFactorDevice: SecondFactorBooleanResult; /** Delete a Snack that the current user owns */ @@ -4981,16 +5021,6 @@ export type MeMutationCreateAccountArgs = { }; -export type MeMutationDeleteAccountArgs = { - accountId: Scalars['ID']['input']; -}; - - -export type MeMutationDeleteSsoUserArgs = { - ssoUserId: Scalars['ID']['input']; -}; - - export type MeMutationDeleteSecondFactorDeviceArgs = { otp?: InputMaybe; userSecondFactorDeviceId: Scalars['ID']['input']; @@ -5074,6 +5104,42 @@ export type MeteredBillingStatus = { EAS_UPDATE: Scalars['Boolean']['output']; }; +export enum MetricsCacheStatus { + Hit = 'HIT', + Miss = 'MISS', + Pass = 'PASS' +} + +export type MetricsFilters = { + cacheStatus?: InputMaybe>; + continent?: InputMaybe>; + hasCustomDomainOrigin?: InputMaybe; + isAsset?: InputMaybe; + isCrash?: InputMaybe; + isVerifiedBot?: InputMaybe; + method?: InputMaybe>; + os?: InputMaybe>; + pathname?: InputMaybe; + status?: InputMaybe>; + statusType?: InputMaybe>; +}; + +export enum MetricsRequestMethod { + Delete = 'DELETE', + Get = 'GET', + Options = 'OPTIONS', + Post = 'POST', + Put = 'PUT' +} + +export enum MetricsStatusType { + ClientError = 'CLIENT_ERROR', + None = 'NONE', + Redirect = 'REDIRECT', + ServerError = 'SERVER_ERROR', + Successful = 'SUCCESSFUL' +} + export type MetricsTimespan = { end: Scalars['DateTime']['input']; start: Scalars['DateTime']['input']; @@ -5371,8 +5437,6 @@ export type RobotMutation = { __typename?: 'RobotMutation'; /** Create a Robot and grant it Permissions on an Account */ createRobotForAccount: Robot; - /** Delete a Robot */ - deleteRobot: DeleteRobotResult; /** Schedule deletion of a Robot */ scheduleRobotDeletion: BackgroundJobReceipt; /** Update a Robot */ @@ -5387,11 +5451,6 @@ export type RobotMutationCreateRobotForAccountArgs = { }; -export type RobotMutationDeleteRobotArgs = { - id: Scalars['String']['input']; -}; - - export type RobotMutationScheduleRobotDeletionArgs = { id: Scalars['ID']['input']; }; @@ -5568,11 +5627,17 @@ export type RootQuery = { /** Top-level query object for querying Audit Logs. */ auditLogs: AuditLogQuery; backgroundJobReceipt: BackgroundJobReceiptQuery; + /** Top-level query object for querying Branchs. */ + branches: BranchQuery; /** Top-level query object for querying annotations. */ buildAnnotations: BuildAnnotationsQuery; /** Top-level query object for querying BuildPublicData publicly. */ buildPublicData: BuildPublicDataQuery; builds: BuildQuery; + /** Top-level query object for querying Channels. */ + channels: ChannelQuery; + /** Top-level query object for querying Deployments. */ + deployments: DeploymentQuery; /** Top-level query object for querying Experimentation configuration. */ experimentation: ExperimentationQuery; /** Top-level query object for querying GitHub App information and resources it has access to. */ @@ -5597,6 +5662,8 @@ export type RootQuery = { meUserActor?: Maybe; /** @deprecated Snacks and apps should be queried separately */ project: ProjectQuery; + /** Top-level query object for querying Runtimes. */ + runtimes: RuntimeQuery; snack: SnackQuery; /** Top-level query object for querying Expo status page services. */ statuspageService: StatuspageServiceQuery; @@ -5681,6 +5748,17 @@ export type RuntimeFilterInput = { branchId?: InputMaybe; }; +export type RuntimeQuery = { + __typename?: 'RuntimeQuery'; + /** Query a Runtime by ID */ + byId: Runtime; +}; + + +export type RuntimeQueryByIdArgs = { + runtimeId: Scalars['ID']['input']; +}; + /** Represents the connection over the runtime edge of an App */ export type RuntimesConnection = { __typename?: 'RuntimesConnection'; @@ -6415,11 +6493,17 @@ export type UpdateInfoGroup = { export type UpdateInsights = { __typename?: 'UpdateInsights'; + cumulativeMetricsOverTime: CumulativeMetricsOverTimeData; id: Scalars['ID']['output']; totalUniqueUsers: Scalars['Int']['output']; }; +export type UpdateInsightsCumulativeMetricsOverTimeArgs = { + timespan: InsightsTimespan; +}; + + export type UpdateInsightsTotalUniqueUsersArgs = { timespan: InsightsTimespan; }; @@ -7174,6 +7258,7 @@ export type WorkerDeploymentLogsArgs = { export type WorkerDeploymentMetricsArgs = { + filters?: InputMaybe; timespan: MetricsTimespan; }; @@ -7264,24 +7349,79 @@ export type WorkerDeploymentLogs = { export type WorkerDeploymentMetrics = { __typename?: 'WorkerDeploymentMetrics'; - groups: Array>; - id: Scalars['ID']['output']; + byBrowser: Array; + byContinent: Array; + byOS: Array; interval: Scalars['Int']['output']; - summary: WorkerDeploymentMetricsData; + summary: WorkerDeploymentMetricsNode; + timeseries: Array; }; -export type WorkerDeploymentMetricsData = { - __typename?: 'WorkerDeploymentMetricsData'; - crashesSum?: Maybe; - durationP50?: Maybe; - durationP90?: Maybe; - durationP99?: Maybe; - requestsSum?: Maybe; +export type WorkerDeploymentMetricsBrowserEdge = { + __typename?: 'WorkerDeploymentMetricsBrowserEdge'; + browser?: Maybe; + node: WorkerDeploymentMetricsNode; +}; + +export type WorkerDeploymentMetricsContinentEdge = { + __typename?: 'WorkerDeploymentMetricsContinentEdge'; + continent: ContinentCode; + node: WorkerDeploymentMetricsNode; +}; + +export type WorkerDeploymentMetricsNode = { + __typename?: 'WorkerDeploymentMetricsNode'; + assetsPerMs?: Maybe; + assetsSum: Scalars['Int']['output']; + cacheHitRatio: Scalars['Float']['output']; + cacheHitRatioP50: Scalars['Float']['output']; + cacheHitRatioP90: Scalars['Float']['output']; + cacheHitRatioP99: Scalars['Float']['output']; + cacheHitsPerMs?: Maybe; + cacheHitsSum: Scalars['Int']['output']; + cachePassRatio: Scalars['Float']['output']; + cachePassRatioP50: Scalars['Float']['output']; + cachePassRatioP90: Scalars['Float']['output']; + cachePassRatioP99: Scalars['Float']['output']; + clientErrorRatio: Scalars['Float']['output']; + clientErrorRatioP50: Scalars['Float']['output']; + clientErrorRatioP90: Scalars['Float']['output']; + clientErrorRatioP99: Scalars['Float']['output']; + crashRatio: Scalars['Float']['output']; + crashRatioP50: Scalars['Float']['output']; + crashRatioP90: Scalars['Float']['output']; + crashRatioP99: Scalars['Float']['output']; + crashesPerMs?: Maybe; + crashesSum: Scalars['Int']['output']; + duration: Scalars['Float']['output']; + durationP50: Scalars['Float']['output']; + durationP90: Scalars['Float']['output']; + durationP99: Scalars['Float']['output']; + requestsPerMs?: Maybe; + requestsSum: Scalars['Int']['output']; + sampleRate: Scalars['Float']['output']; + serverErrorRatio: Scalars['Float']['output']; + serverErrorRatioP50: Scalars['Float']['output']; + serverErrorRatioP90: Scalars['Float']['output']; + serverErrorRatioP99: Scalars['Float']['output']; + staleIfErrorPerMs?: Maybe; + staleIfErrorSum: Scalars['Int']['output']; + staleWhileRevalidatePerMs?: Maybe; + staleWhileRevalidateSum: Scalars['Int']['output']; +}; + +export type WorkerDeploymentMetricsOperatingSystemEdge = { + __typename?: 'WorkerDeploymentMetricsOperatingSystemEdge'; + node: WorkerDeploymentMetricsNode; + os?: Maybe; }; -export type WorkerDeploymentMetricsEdge = { - __typename?: 'WorkerDeploymentMetricsEdge'; - node: WorkerDeploymentMetricsData; +export type WorkerDeploymentMetricsTimeseriesEdge = { + __typename?: 'WorkerDeploymentMetricsTimeseriesEdge'; + byBrowser: Array; + byContinent: Array; + byOS: Array; + node?: Maybe; timestamp: Scalars['DateTime']['output']; }; @@ -8422,3 +8562,19 @@ export type IosAppBuildCredentialsFragment = { __typename?: 'IosAppBuildCredenti export type CommonIosAppCredentialsWithoutBuildCredentialsFragment = { __typename?: 'IosAppCredentials', id: string, app: { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', githubRepoOwnerName: string, githubRepoName: string } } | null }, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null, appleAppIdentifier: { __typename?: 'AppleAppIdentifier', id: string, bundleIdentifier: string }, pushKey?: { __typename?: 'ApplePushKey', id: string, keyIdentifier: string, updatedAt: any, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null, iosAppCredentialsList: Array<{ __typename?: 'IosAppCredentials', id: string, app: { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', githubRepoOwnerName: string, githubRepoName: string } } | null }, appleAppIdentifier: { __typename?: 'AppleAppIdentifier', id: string, bundleIdentifier: string } }> } | null, appStoreConnectApiKeyForSubmissions?: { __typename?: 'AppStoreConnectApiKey', id: string, issuerIdentifier: string, keyIdentifier: string, name?: string | null, roles?: Array | null, createdAt: any, updatedAt: any, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null } | null }; export type CommonIosAppCredentialsFragment = { __typename?: 'IosAppCredentials', id: string, iosAppBuildCredentialsList: Array<{ __typename?: 'IosAppBuildCredentials', id: string, iosDistributionType: IosDistributionType, distributionCertificate?: { __typename?: 'AppleDistributionCertificate', id: string, certificateP12?: string | null, certificatePassword?: string | null, serialNumber: string, developerPortalIdentifier?: string | null, validityNotBefore: any, validityNotAfter: any, updatedAt: any, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null, iosAppBuildCredentialsList: Array<{ __typename?: 'IosAppBuildCredentials', id: string, iosAppCredentials: { __typename?: 'IosAppCredentials', id: string, app: { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', githubRepoOwnerName: string, githubRepoName: string } } | null }, appleAppIdentifier: { __typename?: 'AppleAppIdentifier', id: string, bundleIdentifier: string } }, provisioningProfile?: { __typename?: 'AppleProvisioningProfile', id: string, developerPortalIdentifier?: string | null } | null }> } | null, provisioningProfile?: { __typename?: 'AppleProvisioningProfile', id: string, expiration: any, developerPortalIdentifier?: string | null, provisioningProfile?: string | null, updatedAt: any, status: string, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null, appleDevices: Array<{ __typename?: 'AppleDevice', id: string, identifier: string, name?: string | null, model?: string | null, deviceClass?: AppleDeviceClass | null, createdAt: any }> } | null }>, app: { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', githubRepoOwnerName: string, githubRepoName: string } } | null }, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null, appleAppIdentifier: { __typename?: 'AppleAppIdentifier', id: string, bundleIdentifier: string }, pushKey?: { __typename?: 'ApplePushKey', id: string, keyIdentifier: string, updatedAt: any, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null, iosAppCredentialsList: Array<{ __typename?: 'IosAppCredentials', id: string, app: { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', githubRepoOwnerName: string, githubRepoName: string } } | null }, appleAppIdentifier: { __typename?: 'AppleAppIdentifier', id: string, bundleIdentifier: string } }> } | null, appStoreConnectApiKeyForSubmissions?: { __typename?: 'AppStoreConnectApiKey', id: string, issuerIdentifier: string, keyIdentifier: string, name?: string | null, roles?: Array | null, createdAt: any, updatedAt: any, appleTeam?: { __typename?: 'AppleTeam', id: string, appleTeamIdentifier: string, appleTeamName?: string | null } | null } | null }; + +export type CreateDeploymentUrlMutationVariables = Exact<{ + appId: Scalars['ID']['input']; + deploymentIdentifier?: InputMaybe; +}>; + + +export type CreateDeploymentUrlMutation = { __typename?: 'RootMutation', deployments: { __typename?: 'DeploymentsMutation', createSignedDeploymentUrl: { __typename?: 'DeploymentSignedUrlResult', pendingWorkerDeploymentId: string, deploymentIdentifier: string, url: string } } }; + +export type AssignDevDomainNameMutationVariables = Exact<{ + appId: Scalars['ID']['input']; + name: Scalars['DevDomainName']['input']; +}>; + + +export type AssignDevDomainNameMutation = { __typename?: 'RootMutation', devDomainName: { __typename?: 'AppDevDomainNameMutation', assignDevDomainName: { __typename?: 'AppDevDomainName', id: string, name: any } } }; diff --git a/packages/eas-cli/src/worker/assets.ts b/packages/eas-cli/src/worker/assets.ts new file mode 100644 index 0000000000..6b3105fe0f --- /dev/null +++ b/packages/eas-cli/src/worker/assets.ts @@ -0,0 +1,149 @@ +import { Gzip, GzipOptions } from 'minizlib'; +import { HashOptions, createHash, randomBytes } from 'node:crypto'; +import fs, { createWriteStream } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { pack } from 'tar-stream'; + +/** Returns whether a file or folder is ignored */ +function isIgnoredName(name: string): boolean { + switch (name) { + // macOS system files + case '.DS_Store': + case '.AppleDouble': + case '.Trashes': + case '__MACOSX': + case '.LSOverride': + return true; + default: + // Backup file name convention + return name.endsWith('~'); + } +} + +/** Creates a temporary file write path */ +async function createTempWritePathAsync(): Promise { + const basename = path.basename(__filename, path.extname(__filename)); + const tmpdir = await fs.promises.realpath(os.tmpdir()); + const random = randomBytes(4).toString('hex'); + return path.resolve(tmpdir, `tmp-${basename}-${process.pid}-${random}`); +} + +/** Computes a SHA512 hash for a file */ +async function computeSha512HashAsync( + filePath: fs.PathLike, + options?: HashOptions +): Promise { + const hash = createHash('sha512', { encoding: 'hex', ...options }); + await pipeline(fs.createReadStream(filePath), hash); + return `${hash.read()}`; +} + +/** A file entry with a gzip-safe (normalized) path and a filesystem path */ +interface RecursiveFileEntry { + normalizedPath: string; + path: string; +} + +/** Lists plain files in base path recursively and outputs normalized paths */ +function listFilesRecursively(basePath: string): AsyncGenerator { + async function* recurseAsync(parentPath?: string): AsyncGenerator { + const target = parentPath ? path.resolve(basePath, parentPath) : basePath; + const entries = await fs.promises.readdir(target, { withFileTypes: true }); + for (const dirent of entries) { + const normalizedPath = parentPath ? `${parentPath}/${dirent.name}` : dirent.name; + if (isIgnoredName(dirent.name)) { + continue; + } else if (dirent.isFile()) { + yield { + normalizedPath, + path: path.resolve(target, dirent.name), + }; + } else if (dirent.isDirectory()) { + yield* recurseAsync(normalizedPath); + } + } + } + return recurseAsync(); +} + +interface AssetMapOptions { + hashOptions?: HashOptions; +} + +/** Mapping of normalized file paths to a SHA512 hash */ +export type AssetMap = Record; + +/** Creates an asset map of a given target path */ +async function createAssetMapAsync( + assetPath: string, + options?: AssetMapOptions +): Promise { + const map: AssetMap = Object.create(null); + for await (const file of listFilesRecursively(assetPath)) { + map[file.normalizedPath] = await computeSha512HashAsync(file.path, options?.hashOptions); + } + return map; +} + +interface WorkerFileEntry { + normalizedPath: string; + path: string; + data: Buffer | string; +} + +/** Reads worker files while normalizing sourcemaps and providing normalized paths */ +async function* listWorkerFilesAsync(workerPath: string): AsyncGenerator { + for await (const file of listFilesRecursively(workerPath)) { + yield { + normalizedPath: file.normalizedPath, + path: file.path, + data: await fs.promises.readFile(file.path), + }; + } +} + +/** Reads files of an asset maps and enumerates normalized paths and data */ +async function* listAssetMapFilesAsync( + assetPath: string, + assetMap: AssetMap +): AsyncGenerator { + for (const normalizedPath in assetMap) { + const filePath = path.resolve(assetPath, normalizedPath.split('/').join(path.sep)); + const data = await fs.promises.readFile(filePath); + yield { + normalizedPath, + path: filePath, + data, + }; + } +} + +/** Entry of a normalized (gzip-safe) path and file data */ +export type FileEntry = readonly [normalizedPath: string, data: Buffer | string]; + +/** Packs file entries into a tar.gz file (path to tgz returned) */ +async function packFilesIterableAsync( + iterable: Iterable | AsyncIterable, + options?: GzipOptions +): Promise { + const writePath = `${await createTempWritePathAsync()}.tar.gz`; + const write = createWriteStream(writePath); + const gzip = new Gzip({ portable: true, ...options }); + const tar = pack(); + const writeTask$ = pipeline(tar, gzip, write); + for await (const file of iterable) { + tar.entry({ name: file[0], type: 'file' }, file[1]); + } + tar.finalize(); + await writeTask$; + return writePath; +} + +export { + createAssetMapAsync, + listWorkerFilesAsync, + listAssetMapFilesAsync, + packFilesIterableAsync, +}; diff --git a/packages/eas-cli/src/worker/deployment.ts b/packages/eas-cli/src/worker/deployment.ts new file mode 100644 index 0000000000..6baea034b8 --- /dev/null +++ b/packages/eas-cli/src/worker/deployment.ts @@ -0,0 +1,89 @@ +import { ExpoConfig } from '@expo/config-types'; +import { CombinedError as GraphqlError } from '@urql/core'; + +import { DeploymentsMutation } from './mutations'; +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import Log from '../log'; +import { promptAsync } from '../prompts'; + +export async function getSignedDeploymentUrlAsync( + graphqlClient: ExpoGraphqlClient, + exp: ExpoConfig, + deploymentVariables: { + appId: string; + deploymentIdentifier?: string | null; + } +): Promise { + try { + return await DeploymentsMutation.createSignedDeploymentUrlAsync( + graphqlClient, + deploymentVariables + ); + } catch (error: any) { + const isMissingDevDomain = (error as GraphqlError)?.graphQLErrors?.some(e => + ['APP_NO_DEV_DOMAIN_NAME'].includes(e?.extensions?.errorCode as string) + ); + + if (!isMissingDevDomain) { + throw error; + } + + await chooseDevDomainNameAsync({ + graphqlClient, + appId: deploymentVariables.appId, + slug: exp.slug, + }); + + return await DeploymentsMutation.createSignedDeploymentUrlAsync( + graphqlClient, + deploymentVariables + ); + } +} + +async function chooseDevDomainNameAsync({ + graphqlClient, + appId, + slug, +}: { + graphqlClient: ExpoGraphqlClient; + appId: string; + slug: string; +}): Promise { + const validationMessage = 'The project does not have a dev domain name.'; + const { name } = await promptAsync({ + type: 'text', + name: 'name', + message: 'Choose a dev domain name for your project:', + validate: value => (value && value.length > 3 ? true : validationMessage), + initial: slug, + }); + + if (!name) { + throw new Error('Prompt failed'); + } + + try { + const success = await DeploymentsMutation.assignDevDomainNameAsync(graphqlClient, { + appId, + name, + }); + + if (!success) { + throw new Error('Failed to assign dev domain name'); + } + } catch (error: any) { + const isChosenNameTaken = (error as GraphqlError)?.graphQLErrors?.some(e => + ['DEV_DOMAIN_NAME_TAKEN'].includes(e?.extensions?.errorCode as string) + ); + + if (isChosenNameTaken) { + Log.error(`The entered dev domain name "${name}" is taken. Choose a different name.`); + await chooseDevDomainNameAsync({ graphqlClient, appId, slug }); + } + + if (!isChosenNameTaken) { + throw error; + } + } +} diff --git a/packages/eas-cli/src/worker/mutations.ts b/packages/eas-cli/src/worker/mutations.ts new file mode 100644 index 0000000000..7e57533e57 --- /dev/null +++ b/packages/eas-cli/src/worker/mutations.ts @@ -0,0 +1,71 @@ +import assert from 'assert'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../graphql/client'; +import { + AssignDevDomainNameMutation, + AssignDevDomainNameMutationVariables, + CreateDeploymentUrlMutation, + CreateDeploymentUrlMutationVariables, +} from '../graphql/generated'; + +export const DeploymentsMutation = { + async createSignedDeploymentUrlAsync( + graphqlClient: ExpoGraphqlClient, + deploymentVariables: { + appId: string; + deploymentIdentifier?: string | null; + } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation createDeploymentUrlMutation($appId: ID!, $deploymentIdentifier: ID) { + deployments { + createSignedDeploymentUrl( + appId: $appId + deploymentIdentifier: $deploymentIdentifier + ) { + pendingWorkerDeploymentId + deploymentIdentifier + url + } + } + } + `, + deploymentVariables + ) + .toPromise() + ); + const url = data.deployments?.createSignedDeploymentUrl.url; + assert(url, 'Deployment URL must be defined'); + return url; + }, + + async assignDevDomainNameAsync( + graphqlClient: ExpoGraphqlClient, + devDomainNameVariables: { appId: string; name: string } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation AssignDevDomainName($appId: ID!, $name: DevDomainName!) { + devDomainName { + assignDevDomainName(appId: $appId, name: $name) { + id + name + } + } + } + `, + devDomainNameVariables + ) + .toPromise() + ); + + return data.devDomainName.assignDevDomainName.name === devDomainNameVariables.name; + }, +}; diff --git a/packages/eas-cli/src/worker/upload.ts b/packages/eas-cli/src/worker/upload.ts new file mode 100644 index 0000000000..3b90978784 --- /dev/null +++ b/packages/eas-cli/src/worker/upload.ts @@ -0,0 +1,177 @@ +import * as https from 'https'; +import createHttpsProxyAgent from 'https-proxy-agent'; +import mime from 'mime'; +import { Gzip } from 'minizlib'; +import fetch, { Headers, HeadersInit, RequestInit, Response } from 'node-fetch'; +import fs, { createReadStream } from 'node:fs'; +import path from 'node:path'; +import promiseRetry from 'promise-retry'; + +const MAX_RETRIES = 4; +const MAX_CONCURRENCY = 10; +const MIN_RETRY_TIMEOUT = 100; +const MAX_UPLOAD_SIZE = 5e8; // 5MB +const MIN_COMPRESSION_SIZE = 5e4; // 50kB + +const isCompressible = (contentType: string | null, size: number): boolean => { + if (size < MIN_COMPRESSION_SIZE) { + // Don't compress small files + return false; + } else if (contentType && /^(?:audio|video|image)\//i.test(contentType)) { + // Never compress images, audio, or videos as they're presumably precompressed + return false; + } else if (contentType && /^application\//i.test(contentType)) { + // Only compress `application/` files if they're marked as XML/JSON/JS + return /(?:xml|json5?|javascript)$/i.test(contentType); + } else { + return true; + } +}; + +export interface UploadParams extends Omit { + filePath: string; + compress?: boolean; + url: string; + method?: string; + headers?: HeadersInit; + body?: undefined; + signal?: AbortSignal; +} + +export interface UploadResult { + params: UploadParams; + response: Response; +} + +let sharedAgent: https.Agent | undefined; +const getAgent = (): https.Agent => { + if (sharedAgent) { + return sharedAgent; + } else if (process.env.https_proxy) { + return (sharedAgent = createHttpsProxyAgent(process.env.https_proxy)); + } else { + return (sharedAgent = new https.Agent({ + keepAlive: true, + maxSockets: MAX_CONCURRENCY, + maxTotalSockets: MAX_CONCURRENCY, + scheduling: 'lifo', + timeout: 4_000, + })); + } +}; + +export async function uploadAsync(params: UploadParams): Promise { + const { + filePath, + signal, + compress, + method = 'POST', + url, + headers: headersInit, + ...requestInit + } = params; + const stat = await fs.promises.stat(params.filePath); + if (stat.size > MAX_UPLOAD_SIZE) { + throw new Error( + `Upload of "${params.filePath}" aborted: File size is greater than the upload limit (>500MB)` + ); + } + + const contentType = mime.getType(path.basename(params.filePath)); + return await promiseRetry( + async retry => { + const headers = new Headers(headersInit); + if (contentType) { + headers.set('content-type', contentType); + } + + let bodyStream: NodeJS.ReadableStream = createReadStream(filePath); + if (compress && isCompressible(contentType, stat.size)) { + const gzip = new Gzip({ portable: true }); + bodyStream.on('error', error => gzip.emit('error', error)); + // @ts-expect-error: Gzip implements a Readable-like interface + bodyStream = bodyStream.pipe(gzip) as NodeJS.ReadableStream; + headers.set('content-encoding', 'gzip'); + } + + let response: Response; + try { + response = await fetch(params.url, { + ...requestInit, + method, + body: bodyStream, + headers, + agent: getAgent(), + // @ts-expect-error: Internal types don't match + signal, + }); + } catch (error) { + return retry(error); + } + + if ( + response.status === 408 || + response.status === 409 || + response.status === 429 || + (response.status >= 500 && response.status <= 599) + ) { + const message = `Upload of "${filePath}" failed: ${response.statusText}`; + const text = await response.text().catch(() => null); + return retry(new Error(text ? `${message}\n${text}` : message)); + } else if (response.status === 413) { + const message = `Upload of "${filePath}" failed: File size exceeded the upload limit`; + throw new Error(message); + } else if (!response.ok) { + throw new Error(`Upload of "${filePath}" failed: ${response.statusText}`); + } + + return { + params, + response, + }; + }, + { + retries: MAX_RETRIES, + minTimeout: MIN_RETRY_TIMEOUT, + randomize: true, + factor: 2, + } + ); +} + +export interface UploadPending { + params: UploadParams; +} + +export type BatchUploadSignal = UploadResult | UploadPending; + +export async function* batchUploadAsync( + uploads: readonly UploadParams[] +): AsyncGenerator { + const controller = new AbortController(); + const queue = new Set>(); + try { + let index = 0; + while (index < uploads.length || queue.size > 0) { + while (queue.size < MAX_CONCURRENCY && index < uploads.length) { + const uploadParams = uploads[index++]; + let uploadPromise: Promise; + queue.add( + (uploadPromise = uploadAsync({ ...uploadParams, signal: controller.signal }).finally(() => + queue.delete(uploadPromise) + )) + ); + yield { params: uploadParams }; + } + yield await Promise.race(queue); + } + } catch (error: any) { + if (typeof error !== 'object' || error.name !== 'AbortError') { + throw error; + } + } finally { + if (queue.size > 0) { + controller.abort(); + } + } +} diff --git a/yarn.lock b/yarn.lock index 064da2bc77..e4ac603916 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3937,6 +3937,13 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/tar-stream@3.1.3": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/tar-stream/-/tar-stream-3.1.3.tgz#f61427229691eda1b7d5719f34acdc4fc8a558ce" + integrity sha512-Zbnx4wpkWBMBSu5CytMbrT5ZpMiF55qgM+EpHzR4yIDu7mv52cej8hTkOc6K+LzpkOAbxwn/m7j3iO+/l42YkQ== + dependencies: + "@types/node" "*" + "@types/tar@6.1.10": version "6.1.10" resolved "https://registry.yarnpkg.com/@types/tar/-/tar-6.1.10.tgz#10b0e12129f4af5909af82a055837116ab06f860" @@ -4696,6 +4703,11 @@ axios@^1.0.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +b4a@^1.6.4: + version "1.6.6" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.6.tgz#a4cc349a3851987c3c4ac2d7785c18744f6da9ba" + integrity sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg== + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -4811,6 +4823,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bare-events@^2.2.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.4.2.tgz#3140cca7a0e11d49b3edc5041ab560659fd8e1f8" + integrity sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q== + base64-js@^1.0.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -7051,6 +7068,11 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" @@ -7714,6 +7736,18 @@ glob@^10.2.2: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" +glob@^10.3.7: + version "10.4.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.2.tgz#bed6b95dade5c1f80b4434daced233aee76160e5" + integrity sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^6.0.1: version "6.0.4" resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" @@ -9053,6 +9087,15 @@ jackspeak@^2.3.5: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^3.1.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.0.tgz#a75763ff36ad778ede6a156d8ee8b124de445b4a" + integrity sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jake@^10.8.5: version "10.8.5" resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" @@ -10084,6 +10127,11 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== +lru-cache@^10.2.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.3.0.tgz#4a4aaf10c84658ab70f79a85a9a3f1e1fb11196b" + integrity sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -10564,6 +10612,19 @@ minipass@^5.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== +minipass@^7.0.4, minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.1.tgz#46d5329d1eb3c83924eff1d3b858ca0a31581012" + integrity sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg== + dependencies: + minipass "^7.0.4" + rimraf "^5.0.5" + minizlib@^2.0.0, minizlib@^2.1.1, minizlib@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -11493,6 +11554,11 @@ p-waterfall@2.1.1: dependencies: p-reduce "^2.0.0" +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + pacote@^12.0.0, pacote@^12.0.2: version "12.0.2" resolved "https://registry.yarnpkg.com/pacote/-/pacote-12.0.2.tgz#14ae30a81fe62ec4fc18c071150e6763e932527c" @@ -11711,6 +11777,14 @@ path-scurry@^1.10.1, path-scurry@^1.6.1: lru-cache "^9.1.1 || ^10.0.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -12027,6 +12101,11 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= +queue-tick@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" + integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -12419,6 +12498,13 @@ rimraf@^4.4.1: dependencies: glob "^9.2.0" +rimraf@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.7.tgz#27bddf202e7d89cb2e0381656380d1734a854a74" + integrity sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg== + dependencies: + glob "^10.3.7" + rimraf@~2.4.0: version "2.4.5" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" @@ -13014,6 +13100,17 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +streamx@^2.15.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.18.0.tgz#5bc1a51eb412a667ebfdcd4e6cf6a6fc65721ac7" + integrity sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ== + dependencies: + fast-fifo "^1.3.2" + queue-tick "^1.0.1" + text-decoder "^1.1.0" + optionalDependencies: + bare-events "^2.2.0" + string-env-interpolation@1.0.1, string-env-interpolation@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz#ad4397ae4ac53fe6c91d1402ad6f6a52862c7152" @@ -13402,6 +13499,15 @@ synckit@^0.9.1: "@pkgr/core" "^0.1.0" tslib "^2.6.2" +tar-stream@3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tar-stream@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" @@ -13468,6 +13574,13 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-decoder@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.1.0.tgz#3379e728fcf4d3893ec1aea35e8c2cac215ef190" + integrity sha512-TmLJNj6UgX8xcUZo4UDStGQtDiTzF7BzWlzn9g7UWrjkpHr5uJTK1ld16wZ3LXb2vb6jH8qU89dW5whuMdXYdw== + dependencies: + b4a "^1.6.4" + text-extensions@^1.0.0: version "1.9.0" resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.9.0.tgz#1853e45fee39c945ce6f6c36b2d659b5aabc2a26"