diff --git a/.gitignore b/.gitignore index 1b2f93c3f3..b3eac39207 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ google-generated-credentials.json _START_PACKAGE .env .vscode +.idea diff --git a/README.md b/README.md index 70364ce8e4..a9a161f9a2 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ ![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/docs/images/n8n-logo.png) -n8n is a free and open node based Workflow Automation Tool. It can be -self-hosted, easily extended, and so also used with internal tools. +n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. n8n.io - Screenshot @@ -17,7 +16,7 @@ received or lost a star. ## Available integrations -n8n has 80+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) +n8n has 100+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) ## Documentation @@ -55,6 +54,15 @@ If you have problems or questions go to our forum, we will then try to help you +## Jobs + +If you are interested in working for n8n and so shape the future of the project +check out our job posts: + +[https://jobs.n8n.io](https://jobs.n8n.io) + + + ## What does n8n mean and how do you pronounce it **Short answer:** It means "nodemation" @@ -80,6 +88,6 @@ Have you found a bug :bug: ? Or maybe you have a nice feature :sparkles: to cont ## License -[Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) +n8n is [fair-code](http://faircode.io) licensed under [**Apache 2.0 with Commons Clause**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) Additional information about license can be found in the [FAQ](https://docs.n8n.io/#/faq?id=license) diff --git a/docker/images/n8n-ubuntu/Dockerfile b/docker/images/n8n-ubuntu/Dockerfile index 2b44afe401..200506f058 100644 --- a/docker/images/n8n-ubuntu/Dockerfile +++ b/docker/images/n8n-ubuntu/Dockerfile @@ -1,4 +1,4 @@ -FROM node:10.16 +FROM node:12.16 ARG N8N_VERSION @@ -6,13 +6,16 @@ RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" RUN \ apt-get update && \ - apt-get -y install graphicsmagick + apt-get -y install graphicsmagick gosu # Set a custom user to not have n8n run as root USER root -RUN npm_config_user=root npm install -g n8n@${N8N_VERSION} +RUN npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} + +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu WORKDIR /data -CMD "n8n" +COPY docker-entrypoint.sh /docker-entrypoint.sh +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/images/n8n-ubuntu/docker-entrypoint.sh b/docker/images/n8n-ubuntu/docker-entrypoint.sh new file mode 100755 index 0000000000..80a252f31e --- /dev/null +++ b/docker/images/n8n-ubuntu/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +if [ -d /root/.n8n ] ; then + chmod o+rx /root + chown -R node /root/.n8n + ln -s /root/.n8n /home/node/ +fi + +if [ "$#" -gt 0 ]; then + # Got started with arguments + exec gosu node "$@" +else + # Got started without arguments + exec gosu node n8n +fi diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index c2a1d2551d..c0997dcabd 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -1,11 +1,11 @@ -FROM node:12.13.0-alpine +FROM node:12.16-alpine ARG N8N_VERSION RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi # Update everything and install needed dependencies -RUN apk add --update graphicsmagick tzdata git +RUN apk add --update graphicsmagick tzdata git tini su-exec # # Set a custom user to not have n8n run as root USER root @@ -13,9 +13,12 @@ USER root # Install n8n and the also temporary all the packages # it needs to build it correctly. RUN apk --update add --virtual build-dependencies python build-base ca-certificates && \ - npm_config_user=root npm install -g n8n@${N8N_VERSION} && \ + npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} && \ apk del build-dependencies +ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu + WORKDIR /data -CMD ["n8n"] +COPY docker-entrypoint.sh /docker-entrypoint.sh +ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index 90dbfe436b..8088150821 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -2,8 +2,7 @@ ![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/docs/images/n8n-logo.png) -n8n is a free and open node based Workflow Automation Tool. It can be -self-hosted, easily extended, and so also used with internal tools. +n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. n8n.io - Screenshot @@ -21,6 +20,7 @@ self-hosted, easily extended, and so also used with internal tools. - [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) - [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) - [Support](#support) +- [Jobs](#jobs) - [Upgrading](#upgrading) - [License](#license) @@ -34,7 +34,7 @@ Slack notification every time a Github repository received or lost a star. ## Available integrations -n8n has 50+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) +n8n has 100+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) ## Documentation @@ -128,11 +128,11 @@ it can not be used anymore as encrypting it is not possible anymore. > may be dropped in the future. Replace the following placeholders with the actual data: - - MONGO_DATABASE - - MONGO_HOST - - MONGO_PORT - - MONGO_USER - - MONGO_PASSWORD + - + - + - + - + - ``` docker run -it --rm \ @@ -151,11 +151,12 @@ A full working setup with docker-compose can be found [here](https://github.com/ #### Use with PostgresDB Replace the following placeholders with the actual data: - - POSTGRES_DATABASE - - POSTGRES_HOST - - POSTGRES_PASSWORD - - POSTGRES_PORT - - POSTGRES_USER + - + - + - + - + - + - ``` docker run -it --rm \ @@ -166,6 +167,7 @@ docker run -it --rm \ -e DB_POSTGRESDB_HOST= \ -e DB_POSTGRESDB_PORT= \ -e DB_POSTGRESDB_USER= \ + -e DB_POSTGRESDB_SCHEMA= \ -e DB_POSTGRESDB_PASSWORD= \ -v ~/.n8n:/root/.n8n \ n8nio/n8n \ @@ -175,6 +177,31 @@ docker run -it --rm \ A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withPostgres/README.md) +#### Use with MySQL + +Replace the following placeholders with the actual data: + - + - + - + - + - + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + -e DB_TYPE=mysqldb \ + -e DB_MYSQLDB_DATABASE= \ + -e DB_MYSQLDB_HOST= \ + -e DB_MYSQLDB_PORT= \ + -e DB_MYSQLDB_USER= \ + -e DB_MYSQLDB_PASSWORD= \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start +``` + + ## Passing Sensitive Data via File To avoid passing sensitive information via environment variables "_FILE" may be @@ -189,6 +216,7 @@ The following environment variables support file input: - DB_POSTGRESDB_PASSWORD_FILE - DB_POSTGRESDB_PORT_FILE - DB_POSTGRESDB_USER_FILE + - DB_POSTGRESDB_SCHEMA_FILE - N8N_BASIC_AUTH_PASSWORD_FILE - N8N_BASIC_AUTH_USER_FILE @@ -253,6 +281,17 @@ If you have problems or questions go to our forum, we will then try to help you + +## Jobs + +If you are interested in working for n8n and so shape the future of the project +check out our job posts: + +[https://jobs.n8n.io](https://jobs.n8n.io) + + + + ## Upgrading Before you upgrade to the latest version make sure to check here if there are any breaking changes which concern you: @@ -262,6 +301,6 @@ Before you upgrade to the latest version make sure to check here if there are an ## License -n8n is licensed under [**Apache 2.0 with Commons Clause**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) +n8n is [fair-code](http://faircode.io) licensed under [**Apache 2.0 with Commons Clause**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) Additional information about license can be found in the [FAQ](https://docs.n8n.io/#/faq?id=license) diff --git a/docker/images/n8n/docker-entrypoint.sh b/docker/images/n8n/docker-entrypoint.sh new file mode 100755 index 0000000000..153d01690d --- /dev/null +++ b/docker/images/n8n/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +if [ -d /root/.n8n ] ; then + chmod o+rx /root + chown -R node /root/.n8n + ln -s /root/.n8n /home/node/ +fi + +if [ "$#" -gt 0 ]; then + # Got started with arguments + exec su-exec node "$@" +else + # Got started without arguments + exec su-exec node n8n +fi diff --git a/docs/README.md b/docs/README.md index 78fe9616b6..3af7de9293 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # n8n Documentation -This is the documentation of n8n a free and open node-based Workflow Automation Tool. +This is the documentation of n8n a free and open [fair-code](http://faircode.io) licensed node-based Workflow Automation Tool. It covers everything from setup, usage to development. It is still work in progress and all contributions are welcome. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index e92096e1e2..b29bbaef53 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -38,4 +38,5 @@ - Links + - [![Jobs](https://n8n.io/favicon.ico ':size=16')Jobs](https://jobs.n8n.io) - [![Website](https://n8n.io/favicon.ico ':size=16')n8n.io](https://n8n.io) diff --git a/docs/configuration.md b/docs/configuration.md index 65dd36f472..25bd371ac6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,6 +77,20 @@ These settings can also be overwritten on a per workflow basis in the workflow settings in the Editor UI. +## Execute In Same Process + +All workflows get executed in their own separate process. This ensures that all CPU cores +get used and that they do not block each other on CPU intensive tasks. Additionally does +the crash of one execution not take down the whole application. The disadvantage is, however, +that it slows down the start-time considerably and uses much more memory. So in case, the +workflows are not CPU intensive and they have to start very fast it is possible to run them +all directly in the main-process with this setting. + +```bash +export EXECUTIONS_PROCESS=main +``` + + ## Exclude Nodes It is possible to not allow users to use nodes of a specific node type. If you, for example, @@ -123,6 +137,19 @@ export NODE_FUNCTION_ALLOW_EXTERNAL=moment,lodash ``` +## SSL + +It is possible to start n8n with SSL enabled by supplying a certificate to use: + + +```bash +export N8N_PROTOCOL=https +export N8N_SSL_KEY=/data/certs/server.key +export N8N_SSL_CERT=/data/certs/server.pem +``` + + + ## Timezone The timezone is set by default to "America/New_York". It gets for example used by the @@ -163,3 +190,52 @@ webhook URLs get registred with external services. ```bash export WEBHOOK_TUNNEL_URL="https://n8n.example.com/" ``` + + +## Configuration via file + +It is also possible to configure n8n via a configuration file. + +It is not necessary to define all values. Only the ones which should be +different from the defaults. + +If needed also multiple files can be supplied to for example have generic +base settings and some specific ones depending on the environment. + +The path to the JSON configuration file to use can be set via the environment +variable `N8N_CONFIG_FILES`. + +```bash +# Single file +export N8N_CONFIG_FILES=/folder/my-config.json + +# Multiple files can be comma-separated +export N8N_CONFIG_FILES=/folder/my-config.json,/folder/production.json +``` + +A possible configuration file could look like this: +```json +{ + "executions": { + "process": "main", + "saveDataOnSuccess": "none" + }, + "generic": { + "timezone": "Europe/Berlin" + }, + "security": { + "basicAuth": { + "active": true, + "user": "frank", + "password": "some-secure-password" + } + }, + "nodes": { + "exclude": "[\"n8n-nodes-base.executeCommand\",\"n8n-nodes-base.writeBinaryFile\"]" + } +} +``` + +All possible values which can be set and their defaults can be found here: + +[https://github.com/n8n-io/n8n/blob/master/packages/cli/config/index.ts](https://github.com/n8n-io/n8n/blob/master/packages/cli/config/index.ts) diff --git a/docs/database.md b/docs/database.md index e55669c294..8fa4325394 100644 --- a/docs/database.md +++ b/docs/database.md @@ -4,6 +4,13 @@ By default, n8n uses SQLite to save credentials, past executions, and workflows. n8n however also supports MongoDB and PostgresDB. +## Shared Settings + +The following environment variables get used by all databases: + + - `DB_TABLE_PREFIX` (default: '') - Prefix for table names + + ## MongoDB !> **WARNING**: Use Postgres if possible! Mongo has problems with saving large @@ -38,6 +45,7 @@ To use PostgresDB as database you can provide the following environment variable - `DB_POSTGRESDB_PORT` (default: 5432) - `DB_POSTGRESDB_USER` (default: 'root') - `DB_POSTGRESDB_PASSWORD` (default: empty) + - `DB_POSTGRESDB_SCHEMA` (default: 'public') ```bash @@ -47,6 +55,31 @@ export DB_POSTGRESDB_HOST=postgresdb export DB_POSTGRESDB_PORT=5432 export DB_POSTGRESDB_USER=n8n export DB_POSTGRESDB_PASSWORD=n8n +export DB_POSTGRESDB_SCHEMA=n8n + +n8n start +``` + +## MySQL + +The compatibility with MySQL was tested, even so, it is advisable to observe the operation of the application with this DB, as it is a new option, recently added. If you spot any problems, feel free to submit a PR. + +To use MySQL as database you can provide the following environment variables: + - `DB_TYPE=mysqldb` + - `DB_MYSQLDB_DATABASE` (default: 'n8n') + - `DB_MYSQLDB_HOST` (default: 'localhost') + - `DB_MYSQLDB_PORT` (default: 3306) + - `DB_MYSQLDB_USER` (default: 'root') + - `DB_MYSQLDB_PASSWORD` (default: empty) + + +```bash +export DB_TYPE=mysqldb +export DB_MYSQLDB_DATABASE=n8n +export DB_MYSQLDB_HOST=mysqldb +export DB_MYSQLDB_PORT=3306 +export DB_MYSQLDB_USER=n8n +export DB_MYSQLDB_PASSWORD=n8n n8n start ``` @@ -68,7 +101,6 @@ should not be too much work: - CockroachDB - MariaDB - Microsoft SQL - - MySQL - Oracle If you can not use any of the currently supported databases for some reason and diff --git a/docs/faq.md b/docs/faq.md index 725a42de79..5cb88b7d48 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -27,31 +27,13 @@ Information about that can be found in the [CONTRIBUTING guide](https://github.c ### What license does n8n use? -n8n is licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) +n8n is [fair-code](http://faircode.io) licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) -### Why does n8n has the Commons Clause attached to the license? - -I love Open Source and the idea that everybody can use and extend what I wrote for free. But as much -as money can not buy you love, love can sadly literally not buy you anything. Especially does it not pay for rent, food, health insurance, and so on. -And even though people can theoretically contribute to a project are the main drivers which push a project -forward the most time very few and normally the creators or the company behind the project. So to make sure that -the project improves and stays alive long term did the Commons Clause get attached. That makes sure that -no other person/company can make money directly with n8n. Especially not in the way it is planned -to finance further development. For 99.99% of the people it will not make any difference at all but at -the same time does it protect the project. - -As n8n itself depends on and uses a lot of other Open Source projects it is only fair and in our interest -to also help them. So it is planed to contribute a certain percentage of revenue/profit every month to these -projects. How much exactly is not decided yet. - -Started already with the first very small monthly contributions via [Open Collective](https://opencollective.com/n8n). It is not much yet as revenue is zero and profit in minus but it is at least a start. I hope to be able to ramp that up substantially over time. - - -### Is n8n really Open Source? +### Is n8n open-source? No, according to the definition of the [Open Source Initiative (OSI)](https://opensource.org/osd) -is n8n currently not Open Source. The reason is that [Commons Clause](https://commonsclause.com) which takes away some rights got attached to the Apache 2.0 license. +is n8n not open-source. The reason is that [Commons Clause](https://commonsclause.com) which takes away some rights got attached to the Apache 2.0 license. The source code is however open and people and companies can use it totally free. What is however not allowed is to make money directly with n8n. So you can for example not charge other people to host or support n8n. @@ -61,20 +43,17 @@ The support part is mainly there because it was already in the license and I am If you have bigger things planned simply write an email to [license@n8n.io](mailto:license@n8n.io). -### Why do you call n8n Open Source if the Open Source Initiative (OSI) says it is not? +### Why is n8n not open-source but [fair-code](http://faircode.io) licensed instead? -Because it is the best description and people know what Open Source is. It explains the best and fastest -what can be done with the license n8n uses. +I love open-source and the idea that everybody can use and extend what I wrote for free. But as much +as money can not buy you love, love can sadly literally not buy you anything. Especially does it not pay for rent, food, health insurance, and so on. +And even though people can theoretically contribute to a project are the main drivers which push a project +forward the most time very few and normally the creators or the company behind the project. So to make sure that the project improves and stays alive long term did the Commons Clause get attached. That makes sure that no other person/company can make money directly with n8n. Especially not in the way it is planned +to finance further development. For 99.99% of the people it will not make any difference at all but at +the same time does it protect the project. -If you ask people what it means when a project is Open Source they will mention things like: +As n8n itself depends on and uses a lot of other open-source projects it is only fair and in our interest +to also help them. So it is planed to contribute a certain percentage of revenue/profit every month to these +projects. How much exactly is not decided yet. - - The source code is open - - Everybody can use it for free - - It can be extended - -Those are the things people associate with Open Source and what they care about most. And all of the -above can be done with n8n. So there is currently simply no better term to explain it fast and simple. - -It is however also very important to me to not mislead anybody. That is why I try to mention everywhere -directly that the [Commons Clause](https://commonsclause.com) got applied. So that the 0.01% of the people -who care about that difference know about it. +Started already with the first very small monthly contributions via [Open Collective](https://opencollective.com/n8n). It is not much yet as revenue is zero and profit in minus but it is at least a start. I hope to be able to ramp that up substantially over time. diff --git a/docs/license.md b/docs/license.md index 6ca2d92616..ace732e676 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,5 +1,5 @@ # License -n8n is licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) +n8n is [fair-code](http://faircode.io) licensed under [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) -Additional information about the license can be found in the [FAQ](faq.md?id=license). +Additional information about the license can be found in the [FAQ](faq.md?id=license) and [fair-code](http://faircode.io). diff --git a/docs/node-basics.md b/docs/node-basics.md index 15bd8e2c75..dae607fb82 100644 --- a/docs/node-basics.md +++ b/docs/node-basics.md @@ -31,7 +31,7 @@ With the help of expressions, it is possible to set node parameters dynamically An expression could look like this: -My name is: `{{$node["Webhook"].data["query"]["name"]}}` +My name is: `{{$node["Webhook"].json["query"]["name"]}}` This one would return "My name is: " and then attach the value that the node with the name "Webhook" outputs and there select the property "query" and its key "name". So if the node would output this data: @@ -49,6 +49,7 @@ The following special variables are available: - **$binary**: Incoming binary data of a node - **$data**: Incoming JSON data of a node + - **$evaluateExpression**: Evaluates a string as expression - **$env**: Environment variables - **$node**: Data of other nodes (output-data, parameters) - **$parameters**: Parameters of the current node diff --git a/docs/nodes.md b/docs/nodes.md index cf2484c6c7..a3b0fc7b2e 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -75,9 +75,9 @@ Example: ```typescript // Returns the value of the JSON data property "myNumber" of Node "Set" (first item) -const myNumber = $item(0).$node["Set"].data["myNumber"]; +const myNumber = $item(0).$node["Set"].json["myNumber"]; // Like above but data of the 6th item -const myNumber = $item(5).$node["Set"].data["myNumber"]; +const myNumber = $item(5).$node["Set"].json["myNumber"]; // Returns the value of the parameter "channel" of Node "Slack". // If it contains an expression the value will be resolved with the @@ -93,12 +93,28 @@ const channel = $item(9).$node["Slack"].parameter["channel"]; Works exactly like `$item` with the difference that it will always return the data of the first item. ```typescript -const myNumber = $node["Set"].data['myNumber']; +const myNumber = $node["Set"].json['myNumber']; const channel = $node["Slack"].parameter["channel"]; ``` +#### Method: evaluateExpression(expression: string, itemIndex: number) + +Evaluates a given string as expression. +If no `itemIndex` is provided it uses by default in the Function-Node the data of item 0 and +in the Function Item-Node the data of the current item. + +Example: + +```javascript +items[0].json.variable1 = evaluateExpression('{{1+2}}'); +items[0].json.variable2 = evaluateExpression($node["Set"].json["myExpression"], 1); + +return items; +``` + + #### Method: getWorkflowStaticData(type) Gives access to the static workflow data. @@ -114,7 +130,7 @@ same in the whole workflow. And every node in the workflow can access it. The no Example: -```typescript +```javascript // Get the global workflow static data const staticData = getWorkflowStaticData('global'); // Get the static data of the node diff --git a/docs/sensitive-data.md b/docs/sensitive-data.md index 027fd5c63a..7faa6007d6 100644 --- a/docs/sensitive-data.md +++ b/docs/sensitive-data.md @@ -13,5 +13,6 @@ The following environment variables support file input: - DB_POSTGRESDB_PASSWORD_FILE - DB_POSTGRESDB_PORT_FILE - DB_POSTGRESDB_USER_FILE + - DB_POSTGRESDB_SCHEMA_FILE - N8N_BASIC_AUTH_PASSWORD_FILE - N8N_BASIC_AUTH_USER_FILE diff --git a/docs/workflow.md b/docs/workflow.md index 314715d8c5..7df5c671f3 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -37,6 +37,7 @@ The "Error Trigger" node will trigger in case the execution fails and receives i { "execution": { "id": "231", + "url": "https://n8n.example.com/execution/231", "retryOf": "34", "error": { "message": "Example Error Message", @@ -56,6 +57,7 @@ The "Error Trigger" node will trigger in case the execution fails and receives i All information is always present except: - **execution.id**: Only present when the execution gets saved in the Database +- **execution.url**: Only present when the execution gets saved in the Database - **execution.retryOf**: Only present when the execution is a retry of a previously failed one diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 33e5118859..cedccc2704 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -30,6 +30,27 @@ it has to get changed to: ``` +## 0.52.0 + +### What changed? + +To make sure that all nodes work similarly, to allow to easily use the value +from other parts of the workflow and to be able to construct the source-date +manually in an expression, the node had to be changed. Instead of getting the +source-date directly from the flow the value has now to be manually set via +an expression. + +### When is action necessary? + +If you currently use "Date & Time"-Nodes. + +### How to upgrade: + +Open the "Date & Time"-Nodes and reference the date that should be converted +via an expression. Also, set the "Property Name" to the name of the property the +converted date should be set on. + + ## 0.37.0 ### What changed? diff --git a/packages/cli/README.md b/packages/cli/README.md index 7495cecc23..be5f42ae8b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,8 +2,7 @@ ![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/docs/images/n8n-logo.png) -n8n is a free and open node based Workflow Automation Tool. It can be -self-hosted, easily extended, and so also used with internal tools. +n8n is a free and open [fair-code](http://faircode.io) licensed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. n8n.io - Screenshot @@ -18,6 +17,7 @@ self-hosted, easily extended, and so also used with internal tools. - [Hosted n8n](#hosted-n8n) - [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) - [Support](#support) +- [Jobs](#jobs) - [Upgrading](#upgrading) - [License](#license) - [Development](#development) @@ -32,7 +32,7 @@ Slack notification every time a Github repository received or lost a star. ## Available integrations -n8n has 80+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) +n8n has 100+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) ## Documentation @@ -84,6 +84,15 @@ If you have problems or questions go to our forum, we will then try to help you +## Jobs + +If you are interested in working for n8n and so shape the future of the project +check out our job posts: + +[https://jobs.n8n.io](https://jobs.n8n.io) + + + ## Upgrading Before you upgrade to the latest version make sure to check here if there are any breaking changes which concern you: @@ -92,7 +101,7 @@ Before you upgrade to the latest version make sure to check here if there are an ## License -[Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) +n8n is [fair-code](http://faircode.io) licensed under [**Apache 2.0 with Commons Clause**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) Additional information about license can be found in the [FAQ](https://docs.n8n.io/#/faq?id=license) diff --git a/packages/cli/commands/execute.ts b/packages/cli/commands/execute.ts index 211f9e6479..cdea6a2a0d 100644 --- a/packages/cli/commands/execute.ts +++ b/packages/cli/commands/execute.ts @@ -2,7 +2,10 @@ import { promises as fs } from 'fs'; import { Command, flags } from '@oclif/command'; import { UserSettings, -} from "n8n-core"; +} from 'n8n-core'; +import { + INode, +} from 'n8n-workflow'; import { ActiveExecutions, @@ -116,14 +119,15 @@ export class Execute extends Command { // Check if the workflow contains the required "Start" node // "requiredNodeTypes" are also defined in editor-ui/views/NodeView.vue const requiredNodeTypes = ['n8n-nodes-base.start']; - let startNodeFound = false; + let startNode: INode | undefined= undefined; for (const node of workflowData!.nodes) { if (requiredNodeTypes.includes(node.type)) { - startNodeFound = true; + startNode = node; + break; } } - if (startNodeFound === false) { + if (startNode === undefined) { // If the workflow does not contain a start-node we can not know what // should be executed and with which data to start. GenericHelpers.logOutput(`The workflow does not contain a "Start" node. So it can not be executed.`); @@ -136,6 +140,7 @@ export class Execute extends Command { const runData: IWorkflowExecutionDataProcess = { credentials, executionMode: 'cli', + startNodes: [startNode.name], workflowData: workflowData!, }; diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 15d225ef16..10dde58b35 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -2,7 +2,7 @@ import * as localtunnel from 'localtunnel'; import { TUNNEL_SUBDOMAIN_ENV, UserSettings, -} from "n8n-core"; +} from 'n8n-core'; import { Command, flags } from '@oclif/command'; const open = require('open'); // import { dirname } from 'path'; @@ -21,10 +21,6 @@ import { } from "../src"; -// // Add support for internationalization -// const fullIcuPath = require.resolve('full-icu'); -// process.env.NODE_ICU_DATA = dirname(fullIcuPath); - let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined; let processExistCode = 0; @@ -181,7 +177,7 @@ export class Start extends Command { Start.openBrowser(); } this.log(`\nPress "o" to open in Browser.`); - process.stdin.on("data", (key: string) => { + process.stdin.on("data", (key) => { if (key === 'o') { Start.openBrowser(); inputText = ''; diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 3df13d0440..adfc2d7e44 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -8,7 +8,7 @@ const config = convict({ database: { type: { doc: 'Type of database to use', - format: ['sqlite', 'mongodb', 'postgresdb'], + format: ['sqlite', 'mongodb', 'mysqldb', 'postgresdb'], default: 'sqlite', env: 'DB_TYPE' }, @@ -20,6 +20,12 @@ const config = convict({ env: 'DB_MONGODB_CONNECTION_URL' } }, + tablePrefix: { + doc: 'Prefix for table names', + format: '*', + default: '', + env: 'DB_TABLE_PREFIX' + }, postgresdb: { database: { doc: 'PostgresDB Database', @@ -51,6 +57,44 @@ const config = convict({ default: 'root', env: 'DB_POSTGRESDB_USER' }, + schema: { + doc: 'PostgresDB Schema', + format: String, + default: 'public', + env: 'DB_POSTGRESDB_SCHEMA' + }, + }, + mysqldb: { + database: { + doc: 'MySQL Database', + format: String, + default: 'n8n', + env: 'DB_MYSQLDB_DATABASE' + }, + host: { + doc: 'MySQL Host', + format: String, + default: 'localhost', + env: 'DB_MYSQLDB_HOST' + }, + password: { + doc: 'MySQL Password', + format: String, + default: '', + env: 'DB_MYSQLDB_PASSWORD' + }, + port: { + doc: 'MySQL Port', + format: Number, + default: 3306, + env: 'DB_MYSQLDB_PORT' + }, + user: { + doc: 'MySQL User', + format: String, + default: 'root', + env: 'DB_MYSQLDB_USER' + }, }, }, @@ -68,6 +112,17 @@ const config = convict({ }, executions: { + + // By default workflows get always executed in their own process. + // If this option gets set to "main" it will run them in the + // main-process instead. + process: { + doc: 'In what process workflows should be executed', + format: ['main', 'own'], + default: 'own', + env: 'EXECUTIONS_PROCESS' + }, + // If a workflow executes all the data gets saved by default. This // could be a problem when a workflow gets executed a lot and processes // a lot of data. To not write the database full it is possible to @@ -133,6 +188,18 @@ const config = convict({ env: 'N8N_PROTOCOL', doc: 'HTTP Protocol via which n8n can be reached' }, + ssl_key: { + format: String, + default: '', + env: 'N8N_SSL_KEY', + doc: 'SSL Key for HTTPS Protocol' + }, + ssl_cert: { + format: String, + default: '', + env: 'N8N_SSL_CERT', + doc: 'SSL Cert for HTTPS Protocol' + }, security: { basicAuth: { @@ -231,6 +298,15 @@ const config = convict({ }); +// Overwrite default configuration with settings which got defined in +// optional configuration files +if (process.env.N8N_CONFIG_FILES !== undefined) { + const configFiles = process.env.N8N_CONFIG_FILES.split(','); + console.log(`\nLoading configuration overwrites from:\n - ${configFiles.join('\n - ')}\n`); + + config.loadFile(configFiles); +} + config.validate({ allowed: 'strict', }); diff --git a/packages/cli/nodemon.json b/packages/cli/nodemon.json index 5bdb290fb2..efb39c6667 100644 --- a/packages/cli/nodemon.json +++ b/packages/cli/nodemon.json @@ -9,6 +9,6 @@ "index.ts", "src" ], - "exec": "npm run build && npm start", + "exec": "npm start", "ext": "ts" -} +} \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 220cf6a24b..fc8436df82 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.44.0", + "version": "0.60.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -20,7 +20,7 @@ }, "scripts": { "build": "tsc", - "dev": "nodemon", + "dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"", "postpack": "rm -f oclif.manifest.json", "prepack": "echo \"Building project...\" && rm -rf dist && tsc -b && oclif-dev manifest", "start": "run-script-os", @@ -64,8 +64,10 @@ "@types/open": "^6.1.0", "@types/parseurl": "^1.3.1", "@types/request-promise-native": "^1.0.15", + "concurrently": "^5.1.0", "jest": "^24.9.0", "nodemon": "^2.0.2", + "p-cancelable": "^2.0.0", "run-script-os": "^1.0.7", "ts-jest": "^24.0.2", "tslint": "^5.17.0", @@ -94,10 +96,11 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mongodb": "^3.2.3", - "n8n-core": "~0.20.0", - "n8n-editor-ui": "~0.31.0", - "n8n-nodes-base": "~0.39.0", - "n8n-workflow": "~0.20.0", + "mysql2": "^2.0.1", + "n8n-core": "~0.29.0", + "n8n-editor-ui": "~0.40.0", + "n8n-nodes-base": "~0.55.0", + "n8n-workflow": "~0.26.0", "open": "^7.0.0", "pg": "^7.11.0", "request-promise-native": "^1.0.7", diff --git a/packages/cli/src/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index f3cfba16f2..10323946f4 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -13,6 +13,7 @@ import { } from '.'; import { ChildProcess } from 'child_process'; +import * as PCancelable from 'p-cancelable'; export class ActiveExecutions { @@ -30,7 +31,7 @@ export class ActiveExecutions { * @returns {string} * @memberof ActiveExecutions */ - add(process: ChildProcess, executionData: IWorkflowExecutionDataProcess): string { + add(executionData: IWorkflowExecutionDataProcess, process?: ChildProcess): string { const executionId = this.nextId++; this.activeExecutions[executionId] = { @@ -44,6 +45,22 @@ export class ActiveExecutions { } + /** + * Attaches an execution + * + * @param {string} executionId + * @param {PCancelable} workflowExecution + * @memberof ActiveExecutions + */ + attachWorkflowExecution(executionId: string, workflowExecution: PCancelable) { + if (this.activeExecutions[executionId] === undefined) { + throw new Error(`No active execution with id "${executionId}" got found to attach to workflowExecution to!`); + } + + this.activeExecutions[executionId].workflowExecution = workflowExecution; + } + + /** * Remove an active execution * @@ -82,13 +99,20 @@ export class ActiveExecutions { // In case something goes wrong make sure that promise gets first // returned that it gets then also resolved correctly. - setTimeout(() => { - if (this.activeExecutions[executionId].process.connected) { - this.activeExecutions[executionId].process.send({ - type: 'stopExecution' - }); - } - }, 1); + if (this.activeExecutions[executionId].process !== undefined) { + // Workflow is running in subprocess + setTimeout(() => { + if (this.activeExecutions[executionId].process!.connected) { + this.activeExecutions[executionId].process!.send({ + type: 'stopExecution' + }); + } + + }, 1); + } else { + // Workflow is running in current process + this.activeExecutions[executionId].workflowExecution!.cancel('Canceled by user'); + } return this.getPostExecutePromise(executionId); } diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 9604fd9de6..57cab86179 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -113,26 +113,28 @@ export class ActiveWorkflowRunner { const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path); if (webhookData === undefined) { - // The requested webhook is not registred - throw new ResponseHelper.ResponseError('The requested webhook is not registred.', 404, 404); + // The requested webhook is not registered + throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); } + const workflowData = await Db.collections.Workflow!.findOne(webhookData.workflowId); + if (workflowData === undefined) { + throw new ResponseHelper.ResponseError(`Could not find workflow with id "${webhookData.workflowId}"`, 404, 404); + } + + const nodeTypes = NodeTypes(); + const workflow = new Workflow({ id: webhookData.workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + // Get the node which has the webhook defined to know where to start from and to // get additional data - const workflowStartNode = webhookData.workflow.getNode(webhookData.node); + const workflowStartNode = workflow.getNode(webhookData.node); if (workflowStartNode === null) { throw new ResponseHelper.ResponseError('Could not find node to process webhook.', 404, 404); } - const executionMode = 'webhook'; - - const workflowData = await Db.collections.Workflow!.findOne(webhookData.workflow.id!); - - if (workflowData === undefined) { - throw new ResponseHelper.ResponseError(`Could not find workflow with id "${webhookData.workflow.id}"`, 404, 404); - } return new Promise((resolve, reject) => { - WebhookHelpers.executeWebhook(webhookData, workflowData, workflowStartNode, executionMode, undefined, req, res, (error: Error | null, data: object) => { + const executionMode = 'webhook'; + WebhookHelpers.executeWebhook(workflow, webhookData, workflowData, workflowStartNode, executionMode, undefined, req, res, (error: Error | null, data: object) => { if (error !== null) { return reject(error); } @@ -202,7 +204,9 @@ export class ActiveWorkflowRunner { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); for (const webhookData of webhooks) { - await this.activeWebhooks!.add(webhookData, mode); + await this.activeWebhooks!.add(workflow, webhookData, mode); + // Save static data! + await WorkflowHelpers.saveStaticData(workflow); } } @@ -214,8 +218,19 @@ export class ActiveWorkflowRunner { * @returns * @memberof ActiveWorkflowRunner */ - removeWorkflowWebhooks(workflowId: string): Promise { - return this.activeWebhooks!.removeByWorkflowId(workflowId); + async removeWorkflowWebhooks(workflowId: string): Promise { + const workflowData = await Db.collections.Workflow!.findOne(workflowId); + if (workflowData === undefined) { + throw new Error(`Could not find workflow with id "${workflowId}"`); + } + + const nodeTypes = NodeTypes(); + const workflow = new Workflow({ id: workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings }); + + await this.activeWebhooks!.removeWorkflow(workflow); + + // Save the static workflow data if needed + await WorkflowHelpers.saveStaticData(workflow); } @@ -330,7 +345,7 @@ export class ActiveWorkflowRunner { throw new Error(`Could not find workflow with id "${workflowId}".`); } const nodeTypes = NodeTypes(); - workflowInstance = new Workflow(workflowId, workflowData.nodes, workflowData.connections, workflowData.active, nodeTypes, workflowData.staticData, workflowData.settings); + workflowInstance = new Workflow({ id: workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings }); const canBeActivated = workflowInstance.checkIfWorkflowCanBeActivated(['n8n-nodes-base.start']); if (canBeActivated === false) { @@ -348,7 +363,7 @@ export class ActiveWorkflowRunner { await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, getTriggerFunctions, getPollFunctions); if (this.activationErrors[workflowId] !== undefined) { - // If there were any activation errors delete them + // If there were activation errors delete them delete this.activationErrors[workflowId]; } } catch (error) { @@ -380,16 +395,9 @@ export class ActiveWorkflowRunner { */ async remove(workflowId: string): Promise { if (this.activeWorkflows !== null) { - const workflowData = this.activeWorkflows.get(workflowId); - // Remove all the webhooks of the workflow await this.removeWorkflowWebhooks(workflowId); - if (workflowData) { - // Save the static workflow data if needed - await WorkflowHelpers.saveStaticData(workflowData.workflow); - } - if (this.activationErrors[workflowId] !== undefined) { // If there were any activation errors delete them delete this.activationErrors[workflowId]; diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 58d234ef3c..a97cbaa837 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -18,6 +18,7 @@ import { MongoDb, PostgresDb, SQLite, + MySQLDb, } from './databases'; export let collections: IDatabaseCollections = { @@ -36,33 +37,58 @@ export async function init(synchronize?: boolean): Promise let connectionOptions: ConnectionOptions; let dbNotExistError: string | undefined; - if (dbType === 'mongodb') { - entities = MongoDb; - connectionOptions = { - type: 'mongodb', - url: await GenericHelpers.getConfigValue('database.mongodb.connectionUrl') as string, - useNewUrlParser: true, - }; - } else if (dbType === 'postgresdb') { - dbNotExistError = 'does not exist'; - entities = PostgresDb; - connectionOptions = { - type: 'postgres', - database: await GenericHelpers.getConfigValue('database.postgresdb.database') as string, - host: await GenericHelpers.getConfigValue('database.postgresdb.host') as string, - password: await GenericHelpers.getConfigValue('database.postgresdb.password') as string, - port: await GenericHelpers.getConfigValue('database.postgresdb.port') as number, - username: await GenericHelpers.getConfigValue('database.postgresdb.user') as string, - }; - } else if (dbType === 'sqlite') { - dbNotExistError = 'no such table:'; - entities = SQLite; - connectionOptions = { - type: 'sqlite', - database: path.join(n8nFolder, 'database.sqlite'), - }; - } else { - throw new Error(`The database "${dbType}" is currently not supported!`); + switch (dbType) { + case 'mongodb': + entities = MongoDb; + connectionOptions = { + type: 'mongodb', + entityPrefix: await GenericHelpers.getConfigValue('database.tablePrefix') as string, + url: await GenericHelpers.getConfigValue('database.mongodb.connectionUrl') as string, + useNewUrlParser: true, + }; + break; + + case 'postgresdb': + dbNotExistError = 'does not exist'; + entities = PostgresDb; + connectionOptions = { + type: 'postgres', + entityPrefix: await GenericHelpers.getConfigValue('database.tablePrefix') as string, + database: await GenericHelpers.getConfigValue('database.postgresdb.database') as string, + host: await GenericHelpers.getConfigValue('database.postgresdb.host') as string, + password: await GenericHelpers.getConfigValue('database.postgresdb.password') as string, + port: await GenericHelpers.getConfigValue('database.postgresdb.port') as number, + username: await GenericHelpers.getConfigValue('database.postgresdb.user') as string, + schema: await GenericHelpers.getConfigValue('database.postgresdb.schema') as string, + }; + break; + + case 'mysqldb': + dbNotExistError = 'does not exist'; + entities = MySQLDb; + connectionOptions = { + type: 'mysql', + database: await GenericHelpers.getConfigValue('database.mysqldb.database') as string, + entityPrefix: await GenericHelpers.getConfigValue('database.tablePrefix') as string, + host: await GenericHelpers.getConfigValue('database.mysqldb.host') as string, + password: await GenericHelpers.getConfigValue('database.mysqldb.password') as string, + port: await GenericHelpers.getConfigValue('database.mysqldb.port') as number, + username: await GenericHelpers.getConfigValue('database.mysqldb.user') as string, + }; + break; + + case 'sqlite': + dbNotExistError = 'no such table:'; + entities = SQLite; + connectionOptions = { + type: 'sqlite', + database: path.join(n8nFolder, 'database.sqlite'), + entityPrefix: await GenericHelpers.getConfigValue('database.tablePrefix') as string, + }; + break; + + default: + throw new Error(`The database "${dbType}" is currently not supported!`); } Object.assign(connectionOptions, { diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 7542d018b1..de81711338 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -18,6 +18,7 @@ import { } from 'n8n-core'; +import * as PCancelable from 'p-cancelable'; import { ObjectID, Repository } from 'typeorm'; import { ChildProcess } from 'child_process'; @@ -90,7 +91,7 @@ export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb { id: string; } -export type DatabaseType = 'mongodb' | 'postgresdb' | 'sqlite'; +export type DatabaseType = 'mongodb' | 'postgresdb' | 'mysqldb' | 'sqlite'; export type SaveExecutionDataType = 'all' | 'none'; export interface IExecutionBase { @@ -185,9 +186,10 @@ export interface IExecutionDeleteFilter { export interface IExecutingWorkflowData { executionData: IWorkflowExecutionDataProcess; - process: ChildProcess; + process?: ChildProcess; startedAt: Date; postExecutePromises: Array>; + workflowExecution?: PCancelable; } export interface IN8nConfig { diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index e4b1ed5821..c716470368 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -49,29 +49,26 @@ export class ResponseError extends Error { export function basicAuthAuthorizationError(resp: Response, realm: string, message?: string) { resp.statusCode = 401; resp.setHeader('WWW-Authenticate', `Basic realm="${realm}"`); - resp.end(message); + resp.json({code: resp.statusCode, message}); } export function jwtAuthAuthorizationError(resp: Response, message?: string) { resp.statusCode = 403; - resp.end(message); + resp.json({code: resp.statusCode, message}); } export function sendSuccessResponse(res: Response, data: any, raw?: boolean, responseCode?: number) { // tslint:disable-line:no-any - res.setHeader('Content-Type', 'application/json'); - if (responseCode !== undefined) { res.status(responseCode); } if (raw === true) { - res.send(JSON.stringify(data)); - return; + res.json(data); } else { - res.send(JSON.stringify({ + res.json({ data - })); + }); } } @@ -103,7 +100,7 @@ export function sendErrorResponse(res: Response, error: ResponseError) { response.stack = error.stack; } - res.status(httpStatusCode).send(JSON.stringify(response)); + res.status(httpStatusCode).json(response); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 9f4e9e77bc..b90410e48c 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1,4 +1,7 @@ import * as express from 'express'; +import { + readFileSync, +} from 'fs'; import { dirname as pathDirname, join as pathJoin, @@ -102,6 +105,10 @@ class App { push: Push.Push; versions: IPackageVersions | undefined; + protocol: string; + sslKey: string; + sslCert: string; + constructor() { this.app = express(); @@ -117,6 +124,10 @@ class App { this.push = Push.getInstance(); this.activeExecutionsInstance = ActiveExecutions.getInstance(); + + this.protocol = config.get('protocol'); + this.sslKey = config.get('ssl_key'); + this.sslCert = config.get('ssl_cert'); } @@ -134,10 +145,10 @@ class App { async config(): Promise { this.versions = await GenericHelpers.getVersions(); - const authIgnoreRegex = new RegExp(`^\/(rest|healthz|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`); + const authIgnoreRegex = new RegExp(`^\/(healthz|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`); // Check for basic auth credentials if activated - const basicAuthActive = config.get('security.basicAuth.active') as boolean; + const basicAuthActive = config.get('security.basicAuth.active') as boolean; if (basicAuthActive === true) { const basicAuthUser = await GenericHelpers.getConfigValue('security.basicAuth.user') as string; if (basicAuthUser === '') { @@ -236,19 +247,28 @@ class App { }); // Support application/json type post data - this.app.use(bodyParser.json({ limit: "16mb", verify: (req, res, buf) => { - // @ts-ignore - req.rawBody = buf; - }})); + this.app.use(bodyParser.json({ + limit: '16mb', verify: (req, res, buf) => { + // @ts-ignore + req.rawBody = buf; + } + })); // Support application/xml type post data // @ts-ignore - this.app.use(bodyParser.xml({ limit: "16mb", xmlParseOptions: { + this.app.use(bodyParser.xml({ limit: '16mb', xmlParseOptions: { normalize: true, // Trim whitespace inside text nodes normalizeTags: true, // Transform tags to lowercase - explicitArray: false // Only put properties in array if length > 1 + explicitArray: false, // Only put properties in array if length > 1 } })); + this.app.use(bodyParser.text({ + limit: '16mb', verify: (req, res, buf) => { + // @ts-ignore + req.rawBody = buf; + } + })); + // Make sure that Vue history mode works properly this.app.use(history({ rewrites: [ @@ -504,7 +524,7 @@ class App { const credentials = await WorkflowCredentials(workflowData.nodes); const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); const nodeTypes = NodeTypes(); - const workflowInstance = new Workflow(workflowData.id, workflowData.nodes, workflowData.connections, false, nodeTypes, undefined, workflowData.settings); + const workflowInstance = new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: false, nodeTypes, staticData: undefined, settings: workflowData.settings }); const needsWebhook = await this.testWebhooks.needsWebhookData(workflowData, workflowInstance, additionalData, executionMode, sessionId, destinationNode); if (needsWebhook === true) { return { @@ -663,6 +683,10 @@ class App { throw new Error('No encryption key got found to encrypt the credentials!'); } + if (incomingData.name === '') { + throw new Error('Credentials have to have a name set!'); + } + // Check if credentials with the same name and type exist already const findQuery = { where: { @@ -703,6 +727,10 @@ class App { const id = req.params.id; + if (incomingData.name === '') { + throw new Error('Credentials have to have a name set!'); + } + // Add the date for newly added node access permissions for (const nodeAccess of incomingData.nodesAccess) { if (!nodeAccess.date) { @@ -838,6 +866,7 @@ class App { return returnData; })); + // ---------------------------------------- // OAuth2-Credential/Auth // ---------------------------------------- @@ -1108,6 +1137,12 @@ class App { workflowData: fullExecutionData.workflowData, }; + const lastNodeExecuted = data!.executionData!.resultData.lastNodeExecuted as string; + + // Remove the old error and the data of the last run of the node that it can be replaced + delete data!.executionData!.resultData.error; + data!.executionData!.resultData.runData[lastNodeExecuted].pop(); + if (req.body.loadWorkflow === true) { // Loads the currently saved workflow to execute instead of the // one saved at the time of the execution. @@ -1117,6 +1152,18 @@ class App { if (data.workflowData === undefined) { throw new Error(`The workflow with the ID "${workflowId}" could not be found and so the data not be loaded for the retry.`); } + + // Replace all of the nodes in the execution stack with the ones of the new workflow + for (const stack of data!.executionData!.executionData!.nodeExecutionStack) { + // Find the data of the last executed node in the new workflow + const node = data.workflowData.nodes.find(node => node.name === stack.node.name); + if (node === undefined) { + throw new Error(`Could not find the node "${stack.node.name}" in workflow. It probably got deleted or renamed. Without it the workflow can sadly not be retried.`); + } + + // Replace the node data in the stack that it really uses the current data + stack.node = node; + } } const workflowRunner = new WorkflowRunner(); @@ -1372,7 +1419,20 @@ export async function start(): Promise { await app.config(); - app.app.listen(PORT, async () => { + let server; + + if (app.protocol === 'https' && app.sslKey && app.sslCert){ + const https = require('https'); + const privateKey = readFileSync(app.sslKey, 'utf8'); + const cert = readFileSync(app.sslCert, 'utf8'); + const credentials = { key: privateKey,cert }; + server = https.createServer(credentials,app.app); + }else{ + const http = require('http'); + server = http.createServer(app.app); + } + + server.listen(PORT, async () => { const versions = await GenericHelpers.getVersions(); console.log(`n8n ready on port ${PORT}`); console.log(`Version: ${versions.cli}`); diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index 540aa9e0f8..61d7213305 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -2,10 +2,12 @@ import * as express from 'express'; import { IResponseCallbackData, + IWorkflowDb, + NodeTypes, Push, ResponseHelper, WebhookHelpers, - IWorkflowDb, + WorkflowHelpers, } from './'; import { @@ -56,24 +58,28 @@ export class TestWebhooks { const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path); if (webhookData === undefined) { - // The requested webhook is not registred - throw new ResponseHelper.ResponseError('The requested webhook is not registred.', 404, 404); - } - - // Get the node which has the webhook defined to know where to start from and to - // get additional data - const workflowStartNode = webhookData.workflow.getNode(webhookData.node); - if (workflowStartNode === null) { - throw new ResponseHelper.ResponseError('Could not find node to process webhook.', 404, 404); + // The requested webhook is not registered + throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); } const webhookKey = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path); + const workflowData = this.testWebhookData[webhookKey].workflowData; + + const nodeTypes = NodeTypes(); + const workflow = new Workflow({ id: webhookData.workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings}); + + // Get the node which has the webhook defined to know where to start from and to + // get additional data + const workflowStartNode = workflow.getNode(webhookData.node); + if (workflowStartNode === null) { + throw new ResponseHelper.ResponseError('Could not find node to process webhook.', 404, 404); + } + return new Promise(async (resolve, reject) => { try { const executionMode = 'manual'; - - const executionId = await WebhookHelpers.executeWebhook(webhookData, this.testWebhookData[webhookKey].workflowData, workflowStartNode, executionMode, this.testWebhookData[webhookKey].sessionId, request, response, (error: Error | null, data: IResponseCallbackData) => { + const executionId = await WebhookHelpers.executeWebhook(workflow, webhookData, this.testWebhookData[webhookKey].workflowData, workflowStartNode, executionMode, this.testWebhookData[webhookKey].sessionId, request, response, (error: Error | null, data: IResponseCallbackData) => { if (error !== null) { return reject(error); } @@ -90,7 +96,7 @@ export class TestWebhooks { // Inform editor-ui that webhook got received if (this.testWebhookData[webhookKey].sessionId !== undefined) { const pushInstance = Push.getInstance(); - pushInstance.send('testWebhookReceived', { workflowId: webhookData.workflow.id, executionId }, this.testWebhookData[webhookKey].sessionId!); + pushInstance.send('testWebhookReceived', { workflowId: webhookData.workflowId, executionId }, this.testWebhookData[webhookKey].sessionId!); } } catch (error) { @@ -100,7 +106,7 @@ export class TestWebhooks { // Remove the webhook clearTimeout(this.testWebhookData[webhookKey].timeout); delete this.testWebhookData[webhookKey]; - this.activeWebhooks!.removeByWorkflowId(webhookData.workflow.id!.toString()); + this.activeWebhooks!.removeWorkflow(workflow); }); } @@ -136,7 +142,10 @@ export class TestWebhooks { timeout, workflowData, }; - await this.activeWebhooks!.add(webhookData, mode); + await this.activeWebhooks!.add(workflow, webhookData, mode); + + // Save static data! + this.testWebhookData[key].workflowData.staticData = workflow.staticData; } return true; @@ -151,6 +160,8 @@ export class TestWebhooks { * @memberof TestWebhooks */ cancelTestWebhook(workflowId: string): boolean { + const nodeTypes = NodeTypes(); + let foundWebhook = false; for (const webhookKey of Object.keys(this.testWebhookData)) { const webhookData = this.testWebhookData[webhookKey]; @@ -173,9 +184,12 @@ export class TestWebhooks { } } + const workflowData = webhookData.workflowData; + const workflow = new Workflow({ id: workflowData.id.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings }); + // Remove the webhook delete this.testWebhookData[webhookKey]; - this.activeWebhooks!.removeByWorkflowId(workflowId); + this.activeWebhooks!.removeWorkflow(workflow); } return foundWebhook; @@ -190,13 +204,21 @@ export class TestWebhooks { return; } - return this.activeWebhooks.removeAll(); + const nodeTypes = NodeTypes(); + + let workflowData: IWorkflowDb; + let workflow: Workflow; + const workflows: Workflow[] = []; + for (const webhookKey of Object.keys(this.testWebhookData)) { + workflowData = this.testWebhookData[webhookKey].workflowData; + workflow = new Workflow({ id: workflowData.id.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings }); + workflows.push(workflow); + } + + return this.activeWebhooks.removeAll(workflows); } - } - - let testWebhooksInstance: TestWebhooks | undefined; export function getInstance(): TestWebhooks { diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index b13cc3b0ea..eb7760d8fc 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -84,9 +84,9 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo * @param {((error: Error | null, data: IResponseCallbackData) => void)} responseCallback * @returns {(Promise)} */ - export async function executeWebhook(webhookData: IWebhookData, workflowData: IWorkflowDb, workflowStartNode: INode, executionMode: WorkflowExecuteMode, sessionId: string | undefined, req: express.Request, res: express.Response, responseCallback: (error: Error | null, data: IResponseCallbackData) => void): Promise { + export async function executeWebhook(workflow: Workflow, webhookData: IWebhookData, workflowData: IWorkflowDb, workflowStartNode: INode, executionMode: WorkflowExecuteMode, sessionId: string | undefined, req: express.Request, res: express.Response, responseCallback: (error: Error | null, data: IResponseCallbackData) => void): Promise { // Get the nodeType to know which responseMode is set - const nodeType = webhookData.workflow.nodeTypes.getByName(workflowStartNode.type); + const nodeType = workflow.nodeTypes.getByName(workflowStartNode.type); if (nodeType === undefined) { const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known.`; responseCallback(new Error(errorMessage), {}); @@ -94,8 +94,8 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo } // Get the responseMode - const responseMode = webhookData.workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseMode'], 'onReceived'); - const responseCode = webhookData.workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseCode'], 200) as number; + const responseMode = workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseMode'], 'onReceived'); + const responseCode = workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseCode'], 200) as number; if (!['onReceived', 'lastNode'].includes(responseMode as string)) { // If the mode is not known we error. Is probably best like that instead of using @@ -122,7 +122,7 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo let webhookResultData: IWebhookResponseData; try { - webhookResultData = await webhookData.workflow.runWebhook(webhookData, workflowStartNode, additionalData, NodeExecuteFunctions, executionMode); + webhookResultData = await workflow.runWebhook(webhookData, workflowStartNode, additionalData, NodeExecuteFunctions, executionMode); } catch (e) { // Send error response to webhook caller const errorMessage = 'Workflow Webhook Error: Workflow could not be started!'; @@ -287,22 +287,28 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo return data; } - const responseData = webhookData.workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseData'], 'firstEntryJson'); + const responseData = workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseData'], 'firstEntryJson'); if (didSendResponse === false) { let data: IDataObject | IDataObject[]; if (responseData === 'firstEntryJson') { // Return the JSON data of the first entry + + if (returnData.data!.main[0]![0] === undefined) { + responseCallback(new Error('No item to return got found.'), {}); + didSendResponse = true; + } + data = returnData.data!.main[0]![0].json; - const responsePropertyName = webhookData.workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responsePropertyName'], undefined); + const responsePropertyName = workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responsePropertyName'], undefined); if (responsePropertyName !== undefined) { data = get(data, responsePropertyName as string) as IDataObject; } - const responseContentType = webhookData.workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseContentType'], undefined); + const responseContentType = workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseContentType'], undefined); if (responseContentType !== undefined) { // Send the webhook response manually to be able to set the content-type @@ -324,12 +330,18 @@ export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflo } else if (responseData === 'firstEntryBinary') { // Return the binary data of the first entry data = returnData.data!.main[0]![0]; + + if (data === undefined) { + responseCallback(new Error('No item to return got found.'), {}); + didSendResponse = true; + } + if (data.binary === undefined) { responseCallback(new Error('No binary data to return got found.'), {}); didSendResponse = true; } - const responseBinaryPropertyName = webhookData.workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseBinaryPropertyName'], 'data'); + const responseBinaryPropertyName = workflow.getSimpleParameterValue(workflowStartNode, webhookData.webhookDescription['responseBinaryPropertyName'], 'data'); if (responseBinaryPropertyName === undefined && didSendResponse === false) { responseCallback(new Error('No "responseBinaryPropertyName" is set.'), {}); diff --git a/packages/cli/src/WorkflowCredentials.ts b/packages/cli/src/WorkflowCredentials.ts index f46e7661bd..bd02acabf7 100644 --- a/packages/cli/src/WorkflowCredentials.ts +++ b/packages/cli/src/WorkflowCredentials.ts @@ -13,7 +13,7 @@ export async function WorkflowCredentials(nodes: INode[]): Promise { + const executionsProcess = config.get('executions.process') as string; + if (executionsProcess === 'main') { + return this.runMainProcess(data, loadStaticData); + } + + return this.runSubprocess(data, loadStaticData); + } + + + /** + * Run the workflow in current process + * + * @param {IWorkflowExecutionDataProcess} data + * @param {boolean} [loadStaticData] If set will the static data be loaded from + * the workflow and added to input data + * @returns {Promise} + * @memberof WorkflowRunner + */ + async runMainProcess(data: IWorkflowExecutionDataProcess, loadStaticData?: boolean): Promise { + if (loadStaticData === true && data.workflowData.id) { + data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(data.workflowData.id as string); + } + + const nodeTypes = NodeTypes(); + + const workflow = new Workflow({ id: data.workflowData.id as string | undefined, name: data.workflowData.name, nodes: data.workflowData!.nodes, connections: data.workflowData!.connections, active: data.workflowData!.active, nodeTypes, staticData: data.workflowData!.staticData }); + const additionalData = await WorkflowExecuteAdditionalData.getBase(data.credentials); + + // Register the active execution + const executionId = this.activeExecutions.add(data, undefined); + + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); + + let workflowExecution: PCancelable; + if (data.executionData !== undefined) { + const workflowExecute = new WorkflowExecute(additionalData, data.executionMode, data.executionData); + workflowExecution = workflowExecute.processRunExecutionData(workflow); + } else if (data.runData === undefined || data.startNodes === undefined || data.startNodes.length === 0 || data.destinationNode === undefined) { + // Execute all nodes + + // Can execute without webhook so go on + const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); + workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode); + } else { + // Execute only the nodes between start and destination nodes + const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); + workflowExecution = workflowExecute.runPartialWorkflow(workflow, data.runData, data.startNodes, data.destinationNode); + } + + this.activeExecutions.attachWorkflowExecution(executionId, workflowExecution); + + return executionId; + } + + /** + * Run the workflow + * + * @param {IWorkflowExecutionDataProcess} data + * @param {boolean} [loadStaticData] If set will the static data be loaded from + * the workflow and added to input data + * @returns {Promise} + * @memberof WorkflowRunner + */ + async runSubprocess(data: IWorkflowExecutionDataProcess, loadStaticData?: boolean): Promise { const startedAt = new Date(); const subprocess = fork(pathJoin(__dirname, 'WorkflowRunnerProcess.js')); @@ -97,7 +166,7 @@ export class WorkflowRunner { } // Register the active execution - const executionId = this.activeExecutions.add(subprocess, data); + const executionId = this.activeExecutions.add(data, subprocess); // Check if workflow contains a "executeWorkflow" Node as in this // case we can not know which nodeTypes will be needed and so have diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 4d8ef4d000..250e23cdaa 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -58,7 +58,7 @@ export class WorkflowRunnerProcess { const nodeTypes = NodeTypes(); await nodeTypes.init(nodeTypesData); - this.workflow = new Workflow(this.data.workflowData.id as string | undefined, this.data.workflowData!.nodes, this.data.workflowData!.connections, this.data.workflowData!.active, nodeTypes, this.data.workflowData!.staticData); + this.workflow = new Workflow({ id: this.data.workflowData.id as string | undefined, name: this.data.workflowData.name, nodes: this.data.workflowData!.nodes, connections: this.data.workflowData!.connections, active: this.data.workflowData!.active, nodeTypes, staticData: this.data.workflowData!.staticData, settings: this.data.workflowData!.settings}); const additionalData = await WorkflowExecuteAdditionalData.getBase(this.data.credentials); additionalData.hooks = this.getProcessForwardHooks(); diff --git a/packages/cli/src/databases/index.ts b/packages/cli/src/databases/index.ts index 9263d9230f..48ba5c86eb 100644 --- a/packages/cli/src/databases/index.ts +++ b/packages/cli/src/databases/index.ts @@ -1,9 +1,11 @@ import * as MongoDb from './mongodb'; import * as PostgresDb from './postgresdb'; import * as SQLite from './sqlite'; +import * as MySQLDb from './mysqldb'; export { MongoDb, PostgresDb, SQLite, + MySQLDb, }; diff --git a/packages/cli/src/databases/mysqldb/CredentialsEntity.ts b/packages/cli/src/databases/mysqldb/CredentialsEntity.ts new file mode 100644 index 0000000000..5654581ff0 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/CredentialsEntity.ts @@ -0,0 +1,44 @@ +import { + ICredentialNodeAccess, +} from 'n8n-workflow'; + +import { + ICredentialsDb, +} from '../../'; + +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +export class CredentialsEntity implements ICredentialsDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column({ + length: 128 + }) + name: string; + + @Column('text') + data: string; + + @Index() + @Column({ + length: 32 + }) + type: string; + + @Column('json') + nodesAccess: ICredentialNodeAccess[]; + + @Column('datetime') + createdAt: Date; + + @Column('datetime') + updatedAt: Date; +} diff --git a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts new file mode 100644 index 0000000000..e0c084fcfc --- /dev/null +++ b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts @@ -0,0 +1,51 @@ +import { + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { + IExecutionFlattedDb, + IWorkflowDb, +} from '../../'; + +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + + +@Entity() +export class ExecutionEntity implements IExecutionFlattedDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + data: string; + + @Column() + finished: boolean; + + @Column('varchar') + mode: WorkflowExecuteMode; + + @Column({ nullable: true }) + retryOf: string; + + @Column({ nullable: true }) + retrySuccessId: string; + + @Column('datetime') + startedAt: Date; + + @Column('datetime') + stoppedAt: Date; + + @Column('json') + workflowData: IWorkflowDb; + + @Index() + @Column({ nullable: true }) + workflowId: string; +} diff --git a/packages/cli/src/databases/mysqldb/WorkflowEntity.ts b/packages/cli/src/databases/mysqldb/WorkflowEntity.ts new file mode 100644 index 0000000000..4cca4e62a6 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/WorkflowEntity.ts @@ -0,0 +1,55 @@ +import { + IConnections, + IDataObject, + INode, + IWorkflowSettings, +} from 'n8n-workflow'; + +import { + IWorkflowDb, +} from '../../'; + +import { + Column, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +export class WorkflowEntity implements IWorkflowDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column({ + length: 128 + }) + name: string; + + @Column() + active: boolean; + + @Column('json') + nodes: INode[]; + + @Column('json') + connections: IConnections; + + @Column('datetime') + createdAt: Date; + + @Column('datetime') + updatedAt: Date; + + @Column({ + type: 'json', + nullable: true, + }) + settings?: IWorkflowSettings; + + @Column({ + type: 'json', + nullable: true, + }) + staticData?: IDataObject; +} diff --git a/packages/cli/src/databases/mysqldb/index.ts b/packages/cli/src/databases/mysqldb/index.ts new file mode 100644 index 0000000000..164d67fd0c --- /dev/null +++ b/packages/cli/src/databases/mysqldb/index.ts @@ -0,0 +1,3 @@ +export * from './CredentialsEntity'; +export * from './ExecutionEntity'; +export * from './WorkflowEntity'; diff --git a/packages/core/package.json b/packages/core/package.json index 97673d21ca..4465fd911f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.20.0", + "version": "0.29.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -25,6 +25,7 @@ "dist" ], "devDependencies": { + "@types/cron": "^1.7.1", "@types/crypto-js": "^3.1.43", "@types/express": "^4.16.1", "@types/jest": "^24.0.18", @@ -41,10 +42,11 @@ "dependencies": { "client-oauth2": "^4.2.5", "cron": "^1.7.2", - "crypto-js": "^3.1.9-1", + "crypto-js": "3.1.9-1", "lodash.get": "^4.4.2", "mmmagic": "^0.5.2", - "n8n-workflow": "~0.20.0", + "n8n-workflow": "~0.26.0", + "p-cancelable": "^2.0.0", "request-promise-native": "^1.0.7" }, "jest": { diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts index 6468044d47..17cf753830 100644 --- a/packages/core/src/ActiveWebhooks.ts +++ b/packages/core/src/ActiveWebhooks.ts @@ -1,6 +1,7 @@ import { IWebhookData, WebhookHttpMethod, + Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -29,29 +30,26 @@ export class ActiveWebhooks { * @returns {Promise} * @memberof ActiveWebhooks */ - async add(webhookData: IWebhookData, mode: WorkflowExecuteMode): Promise { - if (webhookData.workflow.id === undefined) { + async add(workflow: Workflow, webhookData: IWebhookData, mode: WorkflowExecuteMode): Promise { + if (workflow.id === undefined) { throw new Error('Webhooks can only be added for saved workflows as an id is needed!'); } - if (this.workflowWebhooks[webhookData.workflow.id] === undefined) { - this.workflowWebhooks[webhookData.workflow.id] = []; + if (this.workflowWebhooks[webhookData.workflowId] === undefined) { + this.workflowWebhooks[webhookData.workflowId] = []; } // Make the webhook available directly because sometimes to create it successfully // it gets called this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)] = webhookData; - const webhookExists = await webhookData.workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); + const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); if (webhookExists === false) { // If webhook does not exist yet create it - await webhookData.workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); + await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); } - // Run the "activate" hooks on the nodes - await webhookData.workflow.runNodeHooks('activate', webhookData, NodeExecuteFunctions, mode); - - this.workflowWebhooks[webhookData.workflow.id].push(webhookData); + this.workflowWebhooks[webhookData.workflowId].push(webhookData); } @@ -73,6 +71,17 @@ export class ActiveWebhooks { } + /** + * Returns the ids of all the workflows which have active webhooks + * + * @returns {string[]} + * @memberof ActiveWebhooks + */ + getWorkflowIds(): string[] { + return Object.keys(this.workflowWebhooks); + } + + /** * Returns key to uniquely identify a webhook * @@ -89,11 +98,13 @@ export class ActiveWebhooks { /** * Removes all webhooks of a workflow * - * @param {string} workflowId + * @param {Workflow} workflow * @returns {boolean} * @memberof ActiveWebhooks */ - async removeByWorkflowId(workflowId: string): Promise { + async removeWorkflow(workflow: Workflow): Promise { + const workflowId = workflow.id!.toString(); + if (this.workflowWebhooks[workflowId] === undefined) { // If it did not exist then there is nothing to remove return false; @@ -105,10 +116,7 @@ export class ActiveWebhooks { // Go through all the registered webhooks of the workflow and remove them for (const webhookData of webhooks) { - await webhookData.workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); - - // Run the "deactivate" hooks on the nodes - await webhookData.workflow.runNodeHooks('deactivate', webhookData, NodeExecuteFunctions, mode); + await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); delete this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)]; } @@ -121,55 +129,16 @@ export class ActiveWebhooks { /** - * Removes all the currently active webhooks + * Removes all the webhooks of the given workflow */ - async removeAll(): Promise { - const workflowIds = Object.keys(this.workflowWebhooks); - + async removeAll(workflows: Workflow[]): Promise { const removePromises = []; - for (const workflowId of workflowIds) { - removePromises.push(this.removeByWorkflowId(workflowId)); + for (const workflow of workflows) { + removePromises.push(this.removeWorkflow(workflow)); } await Promise.all(removePromises); return; } - - // /** - // * Removes a single webhook by its key. - // * Currently not used, runNodeHooks for "deactivate" is missing - // * - // * @param {string} webhookKey - // * @returns {boolean} - // * @memberof ActiveWebhooks - // */ - // removeByWebhookKey(webhookKey: string): boolean { - // if (this.webhookUrls[webhookKey] === undefined) { - // // If it did not exist then there is nothing to remove - // return false; - // } - - // const webhookData = this.webhookUrls[webhookKey]; - - // // Remove from workflow-webhooks - // const workflowWebhooks = this.workflowWebhooks[webhookData.workflowId]; - // for (let index = 0; index < workflowWebhooks.length; index++) { - // if (workflowWebhooks[index].path === webhookData.path) { - // workflowWebhooks.splice(index, 1); - // break; - // } - // } - - // if (workflowWebhooks.length === 0) { - // // When there are no webhooks left for any workflow remove it totally - // delete this.workflowWebhooks[webhookData.workflowId]; - // } - - // // Remove from webhook urls - // delete this.webhookUrls[webhookKey]; - - // return true; - // } - } diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index 8ae57291d4..5576aeed3e 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -69,23 +69,25 @@ export class ActiveWorkflows { async add(id: string, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, getTriggerFunctions: IGetExecuteTriggerFunctions, getPollFunctions: IGetExecutePollFunctions): Promise { console.log('ADD ID (active): ' + id); - this.workflowData[id] = { - workflow - }; + this.workflowData[id] = {}; const triggerNodes = workflow.getTriggerNodes(); let triggerResponse: ITriggerResponse | undefined; + this.workflowData[id].triggerResponses = []; for (const triggerNode of triggerNodes) { triggerResponse = await workflow.runTrigger(triggerNode, getTriggerFunctions, additionalData, 'trigger'); if (triggerResponse !== undefined) { // If a response was given save it - this.workflowData[id].triggerResponse = triggerResponse; + this.workflowData[id].triggerResponses!.push(triggerResponse); } } const pollNodes = workflow.getPollNodes(); - for (const pollNode of pollNodes) { - this.workflowData[id].pollResponse = await this.activatePolling(pollNode, workflow, additionalData, getPollFunctions); + if (pollNodes.length) { + this.workflowData[id].pollResponses = []; + for (const pollNode of pollNodes) { + this.workflowData[id].pollResponses!.push(await this.activatePolling(pollNode, workflow, additionalData, getPollFunctions)); + } } } @@ -166,7 +168,6 @@ export class ActiveWorkflows { const pollResponse = await workflow.runPoll(node, pollFunctions); if (pollResponse !== null) { - // TODO: Run workflow pollFunctions.__emit(pollResponse); } }; @@ -212,12 +213,20 @@ export class ActiveWorkflows { const workflowData = this.workflowData[id]; - if (workflowData.triggerResponse && workflowData.triggerResponse.closeFunction) { - await workflowData.triggerResponse.closeFunction(); + if (workflowData.triggerResponses) { + for (const triggerResponse of workflowData.triggerResponses) { + if (triggerResponse.closeFunction) { + await triggerResponse.closeFunction(); + } + } } - if (workflowData.pollResponse && workflowData.pollResponse.closeFunction) { - await workflowData.pollResponse.closeFunction(); + if (workflowData.pollResponses) { + for (const pollResponse of workflowData.pollResponses) { + if (pollResponse.closeFunction) { + await pollResponse.closeFunction(); + } + } } delete this.workflowData[id]; diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 8e0666a0b1..4b0a5ed451 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -15,7 +15,6 @@ import { ITriggerResponse, IWebhookFunctions as IWebhookFunctionsBase, IWorkflowSettings as IWorkflowSettingsWorkflow, - Workflow, } from 'n8n-workflow'; @@ -137,7 +136,6 @@ export interface INodeInputDataConnections { export interface IWorkflowData { - pollResponse?: IPollResponse; - triggerResponse?: ITriggerResponse; - workflow: Workflow; + pollResponses?: IPollResponse[]; + triggerResponses?: ITriggerResponse[]; } diff --git a/packages/core/src/LoadNodeParameterOptions.ts b/packages/core/src/LoadNodeParameterOptions.ts index fc9bf46b03..e561216065 100644 --- a/packages/core/src/LoadNodeParameterOptions.ts +++ b/packages/core/src/LoadNodeParameterOptions.ts @@ -50,7 +50,7 @@ export class LoadNodeParameterOptions { connections: {}, }; - this.workflow = new Workflow(undefined, workflowData.nodes, workflowData.connections, false, nodeTypes, undefined); + this.workflow = new Workflow({ nodes: workflowData.nodes, connections: workflowData.connections, active: false, nodeTypes }); } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 80d5d0d135..19530fac9a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -28,6 +28,7 @@ import { IWebhookFunctions, IWorkflowDataProxyData, IWorkflowExecuteAdditionalData, + IWorkflowMetadata, NodeHelpers, NodeParameterValue, Workflow, @@ -253,6 +254,19 @@ export function getCredentials(workflow: Workflow, node: INode, type: string, ad +/** + * Returns a copy of the node + * + * @export + * @param {INode} node + * @returns {INode} + */ +export function getNode(node: INode): INode { + return JSON.parse(JSON.stringify(node)); +} + + + /** * Returns the requested resolved (all expressions replaced) node parameters. * @@ -292,6 +306,19 @@ export function getNodeParameter(workflow: Workflow, runExecutionData: IRunExecu +/** + * Returns if execution should be continued even if there was an error. + * + * @export + * @param {INode} node + * @returns {boolean} + */ +export function continueOnFail(node: INode): boolean { + return get(node, 'continueOnFail', false); +} + + + /** * Returns the webhook URL of the webhook with the given name * @@ -369,6 +396,23 @@ export function getWebhookDescription(name: string, workflow: Workflow, node: IN +/** + * Returns the workflow metadata + * + * @export + * @param {Workflow} workflow + * @returns {IWorkflowMetadata} + */ +export function getWorkflowMetadata(workflow: Workflow): IWorkflowMetadata { + return { + id: workflow.id, + name: workflow.name, + active: workflow.active, + }; +} + + + /** * Returns the execute functions the poll nodes have access to. * @@ -392,6 +436,9 @@ export function getExecutePollFunctions(workflow: Workflow, node: INode, additio getMode: (): WorkflowExecuteMode => { return mode; }, + getNode: () => { + return getNode(node); + }, getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; @@ -406,6 +453,9 @@ export function getExecutePollFunctions(workflow: Workflow, node: INode, additio getTimezone: (): string => { return getTimezone(workflow, additionalData); }, + getWorkflow: () => { + return getWorkflowMetadata(workflow); + }, getWorkflowStaticData(type: string): IDataObject { return workflow.getStaticData(type, node); }, @@ -443,6 +493,9 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi getCredentials(type: string): ICredentialDataDecryptedObject | undefined { return getCredentials(workflow, node, type, additionalData); }, + getNode: () => { + return getNode(node); + }, getMode: (): WorkflowExecuteMode => { return mode; }, @@ -460,6 +513,9 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi getTimezone: (): string => { return getTimezone(workflow, additionalData); }, + getWorkflow: () => { + return getWorkflowMetadata(workflow); + }, getWorkflowStaticData(type: string): IDataObject { return workflow.getStaticData(type, node); }, @@ -494,6 +550,12 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunExecutionData, runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IExecuteFunctions { return ((workflow, runExecutionData, connectionInputData, inputData, node) => { return { + continueOnFail: () => { + return continueOnFail(node); + }, + evaluateExpression: (expression: string, itemIndex: number) => { + return workflow.resolveSimpleParameterValue('=' + expression, runExecutionData, runIndex, itemIndex, node.name, connectionInputData); + }, async executeWorkflow(workflowInfo: IExecuteWorkflowInfo, inputData?: INodeExecutionData[]): Promise { // tslint:disable-line:no-any return additionalData.executeWorkflow(workflowInfo, additionalData, inputData); }, @@ -530,12 +592,18 @@ export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunEx getMode: (): WorkflowExecuteMode => { return mode; }, + getNode: () => { + return getNode(node); + }, getRestApiUrl: (): string => { return additionalData.restApiUrl; }, getTimezone: (): string => { return getTimezone(workflow, additionalData); }, + getWorkflow: () => { + return getWorkflowMetadata(workflow); + }, getWorkflowDataProxy: (itemIndex: number): IWorkflowDataProxyData => { const dataProxy = new WorkflowDataProxy(workflow, runExecutionData, runIndex, itemIndex, node.name, connectionInputData); return dataProxy.getDataProxy(); @@ -576,6 +644,13 @@ export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunEx export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: IRunExecutionData, runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, node: INode, itemIndex: number, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IExecuteSingleFunctions { return ((workflow, runExecutionData, connectionInputData, inputData, node, itemIndex) => { return { + continueOnFail: () => { + return continueOnFail(node); + }, + evaluateExpression: (expression: string, evaluateItemIndex: number | undefined) => { + evaluateItemIndex = evaluateItemIndex === undefined ? itemIndex : evaluateItemIndex; + return workflow.resolveSimpleParameterValue('=' + expression, runExecutionData, runIndex, evaluateItemIndex, node.name, connectionInputData); + }, getContext(type: string): IContextObject { return NodeHelpers.getContext(runExecutionData, type, node); }, @@ -610,6 +685,9 @@ export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: getMode: (): WorkflowExecuteMode => { return mode; }, + getNode: () => { + return getNode(node); + }, getRestApiUrl: (): string => { return additionalData.restApiUrl; }, @@ -619,6 +697,9 @@ export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); }, + getWorkflow: () => { + return getWorkflowMetadata(workflow); + }, getWorkflowDataProxy: (): IWorkflowDataProxyData => { const dataProxy = new WorkflowDataProxy(workflow, runExecutionData, runIndex, itemIndex, node.name, connectionInputData); return dataProxy.getDataProxy(); @@ -663,6 +744,9 @@ export function getLoadOptionsFunctions(workflow: Workflow, node: INode, additio getCurrentNodeParameters: (): INodeParameters | undefined => { return JSON.parse('' + additionalData.currentNodeParameters); }, + getNode: () => { + return getNode(node); + }, getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; @@ -709,6 +793,9 @@ export function getExecuteHookFunctions(workflow: Workflow, node: INode, additio getMode: (): WorkflowExecuteMode => { return mode; }, + getNode: () => { + return getNode(node); + }, getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; @@ -732,6 +819,9 @@ export function getExecuteHookFunctions(workflow: Workflow, node: INode, additio getWebhookDescription(name: string): IWebhookDescription | undefined { return getWebhookDescription(name, workflow, node); }, + getWorkflow: () => { + return getWorkflowMetadata(workflow); + }, getWorkflowStaticData(type: string): IDataObject { return workflow.getStaticData(type, node); }, @@ -780,6 +870,9 @@ export function getExecuteWebhookFunctions(workflow: Workflow, node: INode, addi getMode: (): WorkflowExecuteMode => { return mode; }, + getNode: () => { + return getNode(node); + }, getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; @@ -812,6 +905,9 @@ export function getExecuteWebhookFunctions(workflow: Workflow, node: INode, addi getTimezone: (): string => { return getTimezone(workflow, additionalData); }, + getWorkflow: () => { + return getWorkflowMetadata(workflow); + }, getWorkflowStaticData(type: string): IDataObject { return workflow.getStaticData(type, node); }, diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index d8ca8cd50c..1dfb599083 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -1,3 +1,5 @@ +import * as PCancelable from 'p-cancelable'; + import { IConnection, IDataObject, @@ -54,7 +56,7 @@ export class WorkflowExecute { * @returns {(Promise)} * @memberof WorkflowExecute */ - async run(workflow: Workflow, startNode?: INode, destinationNode?: string): Promise { + run(workflow: Workflow, startNode?: INode, destinationNode?: string): PCancelable { // Get the nodes to start workflow execution from startNode = startNode || workflow.getStartNode(destinationNode); @@ -115,7 +117,8 @@ export class WorkflowExecute { * @returns {(Promise)} * @memberof WorkflowExecute */ - async runPartialWorkflow(workflow: Workflow, runData: IRunData, startNodes: string[], destinationNode: string): Promise { + // @ts-ignore + async runPartialWorkflow(workflow: Workflow, runData: IRunData, startNodes: string[], destinationNode: string): PCancelable { let incomingNodeConnections: INodeConnections | undefined; let connection: IConnection; @@ -209,7 +212,7 @@ export class WorkflowExecute { }, }; - return await this.processRunExecutionData(workflow); + return this.processRunExecutionData(workflow); } @@ -444,7 +447,7 @@ export class WorkflowExecute { * @returns {Promise} * @memberof WorkflowExecute */ - async processRunExecutionData(workflow: Workflow): Promise { + processRunExecutionData(workflow: Workflow): PCancelable { const startedAt = new Date(); const workflowIssues = workflow.checkReadyForExecution(); @@ -470,231 +473,253 @@ export class WorkflowExecute { let currentExecutionTry = ''; let lastExecutionTry = ''; - return (async () => { - executionLoop: - while (this.runExecutionData.executionData!.nodeExecutionStack.length !== 0) { - nodeSuccessData = null; - executionError = undefined; - executionData = this.runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData; - executionNode = executionData.node; + return new PCancelable((resolve, reject, onCancel) => { + let gotCancel = false; - this.executeHook('nodeExecuteBefore', [executionNode.name]); + onCancel.shouldReject = false; + onCancel(() => { + gotCancel = true; + }); - // Get the index of the current run - runIndex = 0; - if (this.runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { - runIndex = this.runExecutionData.resultData.runData[executionNode.name].length; - } + const returnPromise = (async () => { - currentExecutionTry = `${executionNode.name}:${runIndex}`; + executionLoop: + while (this.runExecutionData.executionData!.nodeExecutionStack.length !== 0) { - if (currentExecutionTry === lastExecutionTry) { - throw new Error('Did stop execution because execution seems to be in endless loop.'); - } - - if (this.runExecutionData.startData!.runNodeFilter !== undefined && this.runExecutionData.startData!.runNodeFilter!.indexOf(executionNode.name) === -1) { - // If filter is set and node is not on filter skip it, that avoids the problem that it executes - // leafs that are parallel to a selected destinationNode. Normally it would execute them because - // they have the same parent and it executes all child nodes. - continue; - } - - // Check if all the data which is needed to run the node is available - if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) { - // Check if the node has incoming connections - if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) { - let inputConnections: IConnection[][]; - let connectionIndex: number; - - inputConnections = workflow.connectionsByDestinationNode[executionNode.name]['main']; - - for (connectionIndex = 0; connectionIndex < inputConnections.length; connectionIndex++) { - if (workflow.getHighestNode(executionNode.name, 'main', connectionIndex).length === 0) { - // If there is no valid incoming node (if all are disabled) - // then ignore that it has inputs and simply execute it as it is without - // any data - continue; - } - - if (!executionData.data!.hasOwnProperty('main')) { - // ExecutionData does not even have the connection set up so can - // not have that data, so add it again to be executed later - this.runExecutionData.executionData!.nodeExecutionStack.push(executionData); - lastExecutionTry = currentExecutionTry; - continue executionLoop; - } - - // Check if it has the data for all the inputs - // The most nodes just have one but merge node for example has two and data - // of both inputs has to be available to be able to process the node. - if (executionData.data!.main!.length < connectionIndex || executionData.data!.main![connectionIndex] === null) { - // Does not have the data of the connections so add back to stack - this.runExecutionData.executionData!.nodeExecutionStack.push(executionData); - lastExecutionTry = currentExecutionTry; - continue executionLoop; - } - } + // @ts-ignore + if (gotCancel === true) { + return Promise.resolve(); } - } - // Clone input data that nodes can not mess up data of parallel nodes which receive the same data - // TODO: Should only clone if multiple nodes get the same data or when it gets returned to frontned - // is very slow so only do if needed - startTime = new Date().getTime(); + nodeSuccessData = null; + executionError = undefined; + executionData = this.runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData; + executionNode = executionData.node; - let maxTries = 1; - if (executionData.node.retryOnFail === true) { - // TODO: Remove the hardcoded default-values here and also in NodeSettings.vue - maxTries = Math.min(5, Math.max(2, executionData.node.maxTries || 3)); - } + this.executeHook('nodeExecuteBefore', [executionNode.name]); - let waitBetweenTries = 0; - if (executionData.node.retryOnFail === true) { - // TODO: Remove the hardcoded default-values here and also in NodeSettings.vue - waitBetweenTries = Math.min(5000, Math.max(0, executionData.node.waitBetweenTries || 1000)); - } - - for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) { - try { - - if (tryIndex !== 0) { - // Reset executionError from previous error try - executionError = undefined; - if (waitBetweenTries !== 0) { - // TODO: Improve that in the future and check if other nodes can - // be executed in the meantime - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, waitBetweenTries); - }); - } - } - - this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; - nodeSuccessData = await workflow.runNode(executionData.node, executionData.data, this.runExecutionData, runIndex, this.additionalData, NodeExecuteFunctions, this.mode); - - if (nodeSuccessData === null) { - // If null gets returned it means that the node did succeed - // but did not have any data. So the branch should end - // (meaning the nodes afterwards should not be processed) - continue executionLoop; - } - - break; - } catch (error) { - executionError = { - message: error.message, - stack: error.stack, - }; + // Get the index of the current run + runIndex = 0; + if (this.runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { + runIndex = this.runExecutionData.resultData.runData[executionNode.name].length; } - } - // Add the data to return to the user - // (currently does not get cloned as data does not get changed, maybe later we should do that?!?!) + currentExecutionTry = `${executionNode.name}:${runIndex}`; - if (!this.runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { - this.runExecutionData.resultData.runData[executionNode.name] = []; - } - taskData = { - startTime, - executionTime: (new Date().getTime()) - startTime - }; - - if (executionError !== undefined) { - taskData.error = executionError; - - if (executionData.node.continueOnFail === true) { - // Workflow should continue running even if node errors - if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) { - // Simply get the input data of the node if it has any and pass it through - // to the next node - if (executionData.data.main[0] !== null) { - nodeSuccessData = [executionData.data.main[0] as INodeExecutionData[]]; - } - } - } else { - // Node execution did fail so add error and stop execution - this.runExecutionData.resultData.runData[executionNode.name].push(taskData); - - // Add the execution data again so that it can get restarted - this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData); - - this.executeHook('nodeExecuteAfter', [executionNode.name, taskData]); - - break; + if (currentExecutionTry === lastExecutionTry) { + throw new Error('Did stop execution because execution seems to be in endless loop.'); } - } - // Node executed successfully. So add data and go on. - taskData.data = ({ - 'main': nodeSuccessData - } as ITaskDataConnections); + if (this.runExecutionData.startData!.runNodeFilter !== undefined && this.runExecutionData.startData!.runNodeFilter!.indexOf(executionNode.name) === -1) { + // If filter is set and node is not on filter skip it, that avoids the problem that it executes + // leafs that are parallel to a selected destinationNode. Normally it would execute them because + // they have the same parent and it executes all child nodes. + continue; + } - this.executeHook('nodeExecuteAfter', [executionNode.name, taskData]); + // Check if all the data which is needed to run the node is available + if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) { + // Check if the node has incoming connections + if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) { + let inputConnections: IConnection[][]; + let connectionIndex: number; - this.runExecutionData.resultData.runData[executionNode.name].push(taskData); + inputConnections = workflow.connectionsByDestinationNode[executionNode.name]['main']; - if (this.runExecutionData.startData && this.runExecutionData.startData.destinationNode && this.runExecutionData.startData.destinationNode === executionNode.name) { - // If destination node is defined and got executed stop execution - continue; - } - - // Add the nodes to which the current node has an output connection to that they can - // be executed next - if (workflow.connectionsBySourceNode.hasOwnProperty(executionNode.name)) { - if (workflow.connectionsBySourceNode[executionNode.name].hasOwnProperty('main')) { - let outputIndex: string, connectionData: IConnection; - // Go over all the different - - // Add the nodes to be executed - for (outputIndex in workflow.connectionsBySourceNode[executionNode.name]['main']) { - if (!workflow.connectionsBySourceNode[executionNode.name]['main'].hasOwnProperty(outputIndex)) { - continue; - } - - // Go through all the different outputs of this connection - for (connectionData of workflow.connectionsBySourceNode[executionNode.name]['main'][outputIndex]) { - if (!workflow.nodes.hasOwnProperty(connectionData.node)) { - return Promise.reject(new Error(`The node "${executionNode.name}" connects to not found node "${connectionData.node}"`)); + for (connectionIndex = 0; connectionIndex < inputConnections.length; connectionIndex++) { + if (workflow.getHighestNode(executionNode.name, 'main', connectionIndex).length === 0) { + // If there is no valid incoming node (if all are disabled) + // then ignore that it has inputs and simply execute it as it is without + // any data + continue; } - this.addNodeToBeExecuted(workflow, connectionData, parseInt(outputIndex, 10), executionNode.name, nodeSuccessData!, runIndex); + if (!executionData.data!.hasOwnProperty('main')) { + // ExecutionData does not even have the connection set up so can + // not have that data, so add it again to be executed later + this.runExecutionData.executionData!.nodeExecutionStack.push(executionData); + lastExecutionTry = currentExecutionTry; + continue executionLoop; + } + + // Check if it has the data for all the inputs + // The most nodes just have one but merge node for example has two and data + // of both inputs has to be available to be able to process the node. + if (executionData.data!.main!.length < connectionIndex || executionData.data!.main![connectionIndex] === null) { + // Does not have the data of the connections so add back to stack + this.runExecutionData.executionData!.nodeExecutionStack.push(executionData); + lastExecutionTry = currentExecutionTry; + continue executionLoop; + } + } + } + } + + // Clone input data that nodes can not mess up data of parallel nodes which receive the same data + // TODO: Should only clone if multiple nodes get the same data or when it gets returned to frontned + // is very slow so only do if needed + startTime = new Date().getTime(); + + let maxTries = 1; + if (executionData.node.retryOnFail === true) { + // TODO: Remove the hardcoded default-values here and also in NodeSettings.vue + maxTries = Math.min(5, Math.max(2, executionData.node.maxTries || 3)); + } + + let waitBetweenTries = 0; + if (executionData.node.retryOnFail === true) { + // TODO: Remove the hardcoded default-values here and also in NodeSettings.vue + waitBetweenTries = Math.min(5000, Math.max(0, executionData.node.waitBetweenTries || 1000)); + } + + for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) { + // @ts-ignore + if (gotCancel === true) { + return Promise.resolve(); + } + try { + + if (tryIndex !== 0) { + // Reset executionError from previous error try + executionError = undefined; + if (waitBetweenTries !== 0) { + // TODO: Improve that in the future and check if other nodes can + // be executed in the meantime + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, waitBetweenTries); + }); + } + } + + this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; + nodeSuccessData = await workflow.runNode(executionData.node, executionData.data, this.runExecutionData, runIndex, this.additionalData, NodeExecuteFunctions, this.mode); + + if (nodeSuccessData === null) { + // If null gets returned it means that the node did succeed + // but did not have any data. So the branch should end + // (meaning the nodes afterwards should not be processed) + continue executionLoop; + } + + break; + } catch (error) { + executionError = { + message: error.message, + stack: error.stack, + }; + } + } + + // Add the data to return to the user + // (currently does not get cloned as data does not get changed, maybe later we should do that?!?!) + + if (!this.runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { + this.runExecutionData.resultData.runData[executionNode.name] = []; + } + taskData = { + startTime, + executionTime: (new Date().getTime()) - startTime + }; + + if (executionError !== undefined) { + taskData.error = executionError; + + if (executionData.node.continueOnFail === true) { + // Workflow should continue running even if node errors + if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) { + // Simply get the input data of the node if it has any and pass it through + // to the next node + if (executionData.data.main[0] !== null) { + nodeSuccessData = [executionData.data.main[0] as INodeExecutionData[]]; + } + } + } else { + // Node execution did fail so add error and stop execution + this.runExecutionData.resultData.runData[executionNode.name].push(taskData); + + // Add the execution data again so that it can get restarted + this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData); + + this.executeHook('nodeExecuteAfter', [executionNode.name, taskData]); + + break; + } + } + + // Node executed successfully. So add data and go on. + taskData.data = ({ + 'main': nodeSuccessData + } as ITaskDataConnections); + + this.executeHook('nodeExecuteAfter', [executionNode.name, taskData]); + + this.runExecutionData.resultData.runData[executionNode.name].push(taskData); + + if (this.runExecutionData.startData && this.runExecutionData.startData.destinationNode && this.runExecutionData.startData.destinationNode === executionNode.name) { + // If destination node is defined and got executed stop execution + continue; + } + + // Add the nodes to which the current node has an output connection to that they can + // be executed next + if (workflow.connectionsBySourceNode.hasOwnProperty(executionNode.name)) { + if (workflow.connectionsBySourceNode[executionNode.name].hasOwnProperty('main')) { + let outputIndex: string, connectionData: IConnection; + // Go over all the different + + // Add the nodes to be executed + for (outputIndex in workflow.connectionsBySourceNode[executionNode.name]['main']) { + if (!workflow.connectionsBySourceNode[executionNode.name]['main'].hasOwnProperty(outputIndex)) { + continue; + } + + // Go through all the different outputs of this connection + for (connectionData of workflow.connectionsBySourceNode[executionNode.name]['main'][outputIndex]) { + if (!workflow.nodes.hasOwnProperty(connectionData.node)) { + return Promise.reject(new Error(`The node "${executionNode.name}" connects to not found node "${connectionData.node}"`)); + } + + this.addNodeToBeExecuted(workflow, connectionData, parseInt(outputIndex, 10), executionNode.name, nodeSuccessData!, runIndex); + } } } } } - } - return Promise.resolve(); - })() - .then(async () => { - return this.processSuccessExecution(startedAt, workflow, executionError); - }) - .catch(async (error) => { - const fullRunData = this.getFullRunData(startedAt); + return Promise.resolve(); + })() + .then(async () => { + return this.processSuccessExecution(startedAt, workflow, executionError); + }) + .catch(async (error) => { + const fullRunData = this.getFullRunData(startedAt); - fullRunData.data.resultData.error = { - message: error.message, - stack: error.stack, - }; + fullRunData.data.resultData.error = { + message: error.message, + stack: error.stack, + }; - // Check if static data changed - let newStaticData: IDataObject | undefined; - if (workflow.staticData.__dataChanged === true) { - // Static data of workflow changed - newStaticData = workflow.staticData; - } + // Check if static data changed + let newStaticData: IDataObject | undefined; + if (workflow.staticData.__dataChanged === true) { + // Static data of workflow changed + newStaticData = workflow.staticData; + } - await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]); + await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]); - return fullRunData; + return fullRunData; + }); + + return returnPromise.then(resolve); }); - } - async processSuccessExecution(startedAt: Date, workflow: Workflow, executionError?: IExecutionError): Promise { + // @ts-ignore + async processSuccessExecution(startedAt: Date, workflow: Workflow, executionError?: IExecutionError): PCancelable { const fullRunData = this.getFullRunData(startedAt); if (executionError !== undefined) { diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts index 9083252c6c..811b90ceff 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/test/WorkflowExecute.test.ts @@ -579,7 +579,7 @@ describe('WorkflowExecute', () => { for (const testData of tests) { test(testData.description, async () => { - const workflowInstance = new Workflow('test', testData.input.workflowData.nodes, testData.input.workflowData.connections, false, nodeTypes); + const workflowInstance = new Workflow({ id: 'test', nodes: testData.input.workflowData.nodes, connections: testData.input.workflowData.connections, active: false, nodeTypes }); const waitPromise = await createDeferredPromise(); const nodeExecutionOrder: string[] = []; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index abbdab8353..00d1fc38a8 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.31.0", + "version": "0.40.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -17,7 +17,7 @@ "build": "vue-cli-service build", "dev": "npm run serve", "lint": "vue-cli-service lint", - "serve": "VUE_APP_URL_BASE_API=http://localhost:5678/ vue-cli-service serve", + "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vue-cli-service serve", "test": "npm run test:unit", "tslint": "tslint -p tsconfig.json -c tslint.json", "test:e2e": "vue-cli-service test:e2e", @@ -50,6 +50,7 @@ "axios": "^0.19.0", "babel-core": "7.0.0-bridge.0", "babel-eslint": "^10.0.1", + "cross-env": "^7.0.2", "dateformat": "^3.0.3", "element-ui": "~2.13.0", "eslint": "^6.8.0", @@ -63,7 +64,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.20.0", + "n8n-workflow": "~0.26.0", "node-sass": "^4.12.0", "prismjs": "^1.17.1", "quill": "^2.0.0-dev.3", diff --git a/packages/editor-ui/src/components/About.vue b/packages/editor-ui/src/components/About.vue new file mode 100644 index 0000000000..62563a1c25 --- /dev/null +++ b/packages/editor-ui/src/components/About.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialsList.vue b/packages/editor-ui/src/components/CredentialsList.vue index c1d3ac93ff..758adf1a4c 100644 --- a/packages/editor-ui/src/components/CredentialsList.vue +++ b/packages/editor-ui/src/components/CredentialsList.vue @@ -124,7 +124,7 @@ export default mixins( try { this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials)); } catch (error) { - this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:'); + this.$showError(error, 'Proble loading credentials', 'There was a problem loading the credentials:'); this.isDataLoading = false; return; } diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index 46bf5d9a41..ada71f6869 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -102,7 +102,7 @@ export default Vue.extend({ margin-top: 1em; } -/deep/ .expression-dialog { +::v-deep .expression-dialog { .el-dialog__header { padding: 0; } diff --git a/packages/editor-ui/src/components/ExpressionInput.vue b/packages/editor-ui/src/components/ExpressionInput.vue index 9a0f891389..543eb12f22 100644 --- a/packages/editor-ui/src/components/ExpressionInput.vue +++ b/packages/editor-ui/src/components/ExpressionInput.vue @@ -166,11 +166,11 @@ export default mixins( let returnValue; try { returnValue = this.resolveExpression(`=${variableName}`); - } catch (e) { - return 'invalid'; + } catch (error) { + return `[invalid (${error.message})]`; } if (returnValue === undefined) { - return 'not found'; + return '[not found]'; } return returnValue; @@ -258,16 +258,19 @@ export default mixins( } else if (value.charAt(0) === '^') { // Is variable - let displayValue = `{{${value.slice(1)}}}` as string | number | boolean; + let displayValue = `{{${value.slice(1)}}}` as string | number | boolean | null; if (this.resolvedValue) { - displayValue = this.resolveParameterString(displayValue.toString()) as NodeParameterValue; + displayValue = [null, undefined].includes(displayValue as null | undefined) ? '' : displayValue; + displayValue = this.resolveParameterString((displayValue as string).toString()) as NodeParameterValue; } + displayValue = [null, undefined].includes(displayValue as null | undefined) ? '' : displayValue; + editorOperations.push({ attributes: { variable: `{{${value.slice(1)}}}`, }, - insert: displayValue.toString(), + insert: (displayValue as string).toString(), }); } else { // Is text diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index cbf0664e78..71388c2cd3 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -1,5 +1,6 @@ + + + @@ -168,6 +169,7 @@ import { IWorkflowDataUpdate, } from '../Interface'; +import About from '@/components/About.vue'; import CredentialsEdit from '@/components/CredentialsEdit.vue'; import CredentialsList from '@/components/CredentialsList.vue'; import ExecutionsList from '@/components/ExecutionsList.vue'; @@ -196,6 +198,7 @@ export default mixins( .extend({ name: 'MainHeader', components: { + About, CredentialsEdit, CredentialsList, ExecutionsList, @@ -204,6 +207,7 @@ export default mixins( }, data () { return { + aboutDialogVisible: false, isCollapsed: true, credentialNewDialogVisible: false, credentialOpenDialogVisible: false, @@ -251,9 +255,6 @@ export default mixins( currentWorkflow (): string { return this.$route.params.name; }, - versionCli (): string { - return this.$store.getters.versionCli; - }, workflowExecution (): IExecutionResponse | null { return this.$store.getters.getWorkflowExecution; }, @@ -269,6 +270,9 @@ export default mixins( this.$store.commit('setWorkflowExecutionData', null); this.updateNodesExecutionIssues(); }, + closeAboutDialog () { + this.aboutDialogVisible = false; + }, closeWorkflowOpenDialog () { this.workflowOpenDialogVisible = false; }, @@ -434,6 +438,8 @@ export default mixins( this.saveCurrentWorkflow(); } else if (key === 'workflow-save-as') { this.saveCurrentWorkflow(true); + } else if (key === 'help-about') { + this.aboutDialogVisible = true; } else if (key === 'workflow-settings') { this.workflowSettingsDialogVisible = true; } else if (key === 'workflow-new') { @@ -466,6 +472,9 @@ export default mixins(