Skip to main content

A Simple Gateway Hook Example

Creating a Web Sub-Project Folder

The Web folder provides a centralized location for resource files that may be utilized by multiple module hooks. The term Web is used because of how the Ignition webserver interacts with these files from a compiled and installed .modl. In terms of the Gradle build system, it is treated as an additional sub-project for this multi-project build.

The first required configuration change will be in the settings.gradle.kts file. This should be available in the root directory of your project. Once the file is open, scroll down to the bottom of the page and look for the include property. In this property you will notice the familiar keywords :gateway, :client, and :designer. These are important declarations since it is Gradle’s way of understanding all of the sub-project folders that will exist for this build from the root directory. Inside of this list, append the keyword :web and make sure to separate with a comma. Once that is saved on the settings.gradle.kts file, you can now expect your Gradle build executions to recognize the web sub-project folder after it is implemented.

The final requirement to complete the following instructions is to confirm the Module ID of your project since it needs to be referenced when modifying configuration files. This can be done by navigating to the root directory’s build.gradle.kts file and scrolling down to:

IgnitionModule {
….

id.set(“......”)

….
}

The example shown above is expected based on the existing setup you should have from generating a Gradle build against the cloned Ignition Module Tools repository. During the generation process, you should have already specified both a module name and package path that will be reflected there. For this example, the module ID is specified as:

"com.inductiveautomation.ignition.testbuild.TestBuild"

Keep note of the trailing keyword stated after the last period in this package path. In this example, the keyword is TestBuild.

Prepping the Sub-Project Folder

In order to create a sub-project that will represent a resource for other module hooks, additional frameworks will need to be installed locally, starting with NPM and Node. Declarations will then be specified in various configuration files depending on what you’re trying to create. In this example, we only need to worry about configurations between the contents of your web folder and the Gateway hook. Once you have NPM and Node installed, the following steps below will provide you with solution:

On the root directory of your Gradle Project in IntelliJ, create a new directory called web. Ensure the folder is in the same scope as the other module hook folders.

Navigate to the web directory using IntelliJ’s Terminal or through the Command Line. Under the web directory, enter the following command:

npm install lerna

info

Lerna is a separate build tool for organizing specifically Node packages across different sub-directories. When applied to developing custom Ignition modules, separate Node packages will represent different hooks, similar to the root directories representing Gradle’s module hook directories. Even though you will be defining a single custom component under one scope for this example, Lerna can still be used as a foundation for this current implementation and stay flexible for future add ons across the different module hooks.

Inside the web directory, create the following path of directories: packages/gateway. Navigate to the gateway directory. Inside the gateway directory enter the following command:

npm install webpack

You will revisit the packages/ structure with additional Lerna configuration after implementing the gateway directory’s structure.

note

Webpack is another required build tool that will be used for specifically bundling webpage-related resources. In the case of this module example, your environment will provide the minimum of one javascript source file and a corresponding CSS stylesheet. This will allow your custom status page to render simple class-based React components, a React Redux data store, and some CSS styling. In more complex modules, status and configuration pages, as well as perspective components can rely on webpack to bundle multiple JS and css resource files that would be defined in this location.

Defining the Gateway Directory

Inside of the packages/gateway directory, the following files need to be defined:

gateway/
. . . . src/
. . . . . . . . index.js
. . . . package.json
. . . . webpack.config.js

src/index.js

This file is the main index file for the React implementation. Below we are creating a simple class-based React application that will be responsible for displaying some interactable UI to your custom status webpage. For now, it will only contain a <h2> message as a placeholder for a future lesson where you will expand with some UI.

import * as React from 'react';

export class TestPage extends React.Component {
render() {
return <h2>This is a test page...</h2>;
}
}

const MountableApp = TestPage;


export default MountableApp;

package.json

The Node configuration file for our Gateway-scoped resources will be defined here. Node dependencies are declared here since webpack depends on the underlying Node implementation to compile webpage resources.

You'll also see Yarn is used by the web folder's Gradle configuration file, and it will be defined later. You were not required to install it in your project since the Gradle configuration file provided below contains statements that will do that for us during Gradle’s build execution. All contents inside of devDependancies and dependencies apply towards js resources defined above and the webpack.config.js file you will define later below.

{
"name": "gateway",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "yarn run clean && webpack --mode development",
"client": "yarn run build",
"clean": "rimraf dist .awcache",
"deepClean": "yarn run clean && rimraf node_modules __coverage__"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"webpack": "^5.70.0",
"webpack-cli": "^4.10.0"
},
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.25.9",
"babel-loader": "^9.2.1"
}
}

webpack.config.js

In the code snippet below, you are defining the resource directory that will allow webpack to compile your packages/gateway/src contents into a dist/ directory, and then copy the contents of the dist/ directory into a second location for the Ignition Gateway hook to use during the Gradle build execution later. This file also contains the first required set modifications to reflect your Module ID. Search for the following highlighted keywords in the code snippet and make sure to modify their values with the trailing keyword of your module ID’s package path:

  • const LibName = “{Module ID Keyword}”
  • const config = { entry: { {Module ID Keyword} : path.join(__dirname, "./src/index.js") } : }
const webpack = require('webpack'),
path = require('path'),
fs = require('fs'),
AfterBuildPlugin = require('@fiverr/afterbuild-webpack-plugin');

const LibName = "TestBuild";

function copyToResources() {
const generatedResourcesDir = path.resolve(__dirname, '../..', 'build/generated-resources/mounted/js/');

const jsToCopy = path.resolve(__dirname, "dist/", `${LibName}.js`);
const jSResourcePath = path.resolve(generatedResourcesDir, `${LibName}.js`);

const toCopy = [{from:jsToCopy, to: jSResourcePath}];

// if the desired folder doesn't exist, create it
if (!fs.existsSync(generatedResourcesDir)){
fs.mkdirSync(generatedResourcesDir, {recursive: true})
}

toCopy.forEach( file => {
console.log(`copying ${file} into ${generatedResourcesDir}...`);

try {
fs.access(file.from, fs.constants.R_OK, (err) => {
if (!err) {
fs.createReadStream(file.from)
.pipe(fs.createWriteStream(file.to));
} else {
console.log(`Error when attempting to copy ${file.from} into ${file.to}`)
}
});
} catch (err) {
console.error(err);
// rethrow to fail build
throw err;
}
});
}

const config = {

// define our entry point, from which we build our source tree for bundling
entry: {
TestBuild: path.join(__dirname, "./src/index.js")
},

output: {
library: [LibName], // name as it will be accessible by on the webpack when linked as a script
path: path.join(__dirname, "dist"),
filename: `${LibName}.js`,
libraryTarget: "var",
},
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env", "@babel/preset-react"]
}
},
exclude: /(node_modules|bower_components)/,
}
]
},
externals: {
'@inductiveautomation/ignition-lib': "IgnitionLib",
'@inductiveautomation/ignition-react': "IgnitionReact",
'react': 'React',
'react-dom': 'ReactDOM'

},
// Enable source maps for debugging webpack's output. Should be changed for production builds.
devtool: "source-map",

resolve: {
extensions: [".jsx", ".js"],
modules: [
path.resolve(__dirname, "../../node_modules") // look at the local as well as shared node modules when resolving dependencies
]
},
plugins: [
new AfterBuildPlugin(function(stats) {
copyToResources();
})
],
};


module.exports = () => config;

Create Configuration Files for Web Sub-Project

Navigate back to the root web directory. With the contents of packages/gateway complete, you now need to provide the last set of configuration files to bridge them with the rest of the Gradle project.

web/
. . . . packages/
. . . . . . . . gateway/…
. . . . lerna.json
. . . . package.json
. . . . build.gradle.kts

Create the following files:

lerna.json

Now that the packages/ directory is defined, we need to give Lerna a configuration file that will help it recognize its location when compiled:

{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "0.0.0",
"packages": [
"packages/*"
]
}

package.json

Lerna depends on additional script and dependencies from Node and are listed on this file similar to the gateway package’s implementation mentioned earlier:

{
"name": "root",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build": "lerna run build",
"clean": "lerna clean",
"check": "lerna check"
},
"devDependencies": {
"@fiverr/afterbuild-webpack-plugin": "^1.0.0",
"lerna": "^8.1.8"
}
}

build.gradle.kts

This is the standard kotlin version of a Gradle configuration file and will be found under every sub-project directory. In the case of the web sub-project folder, Gradle is being bridged with the package.json file in the same scope to combine resources files to the Gateway hook:

import com.github.gradle.node.yarn.task.YarnTask
import com.github.gradle.node.npm.task.NpmTask

plugins {
java
id("com.github.node-gradle.node") version("3.2.1")
}
// define a variable that describes the path to the mounted gateway folder, where we want to put things eventually
val projectOutput: String by extra("$buildDir/generated-resources/")

// configurations on which versions of Node NPM, and Yarn the gradle build should use. Configuration provided by/to
// the gradle node plugin that"s applied above (com.moowork.node)
node {
version.set("20.17.0")
yarnVersion.set("1.22.22")
npmVersion.set("10.8.2")
download.set(true)
nodeProjectDir.set(file(project.projectDir))

}

// define a gradle task that will install our npm dependencies, extends the YarnTask provided by the node gradle plugin
val yarnPackages by tasks.registering(YarnTask::class) {

description = "Executes 'yarn' at the root of the web/ directory to install npm dependencies for the yarn workspace."
// which yarn command to execute
args.set(listOf("install", "--verbose"))

// set this tasks "inputs" to be any package.json files or yarn.lock files that are found in or below our current
// folder (project.projectDir). This lets the build system avoid repeatedly trying to reinstall dependencies
// which have already been installed. If changes to package.json or yarn.lock are detected, then it will execute
// the install task again.
inputs.files(
fileTree(project.projectDir).matching {
include("**/package.json", "**/yarn.lock")
}
)

// outputs of running 'yarn install'
outputs.dirs(
file("node_modules"),
file("packages/gateway/node_modules")
)

dependsOn("${project.path}:yarn", ":web:npmSetup")
}

// define a gradle task that executes an npm script (defined in the package.json).
val webpack by tasks.registering(NpmTask::class) {
group = "Ignition Module"
description = "Runs 'npm run build', executing the build script of the web project's root package.json"

// same as running "npm run build" in the ./web/ directory.
args.set(listOf("run", "build"))

// we require the installPackages to be done before the npm build (which calls webpack) can run, as we need our dependencies!
dependsOn(yarnPackages)

// we should re-run this task on consecutive builds if we detect changes to any non-generated files, so here we
// define that we wish to have all files -- except those excluded -- as input dependencies for this task.
inputs.files(project.fileTree("packages").matching {
exclude("**/node_modules/**", "**/dist/**", "**/.awcache/**", "**/yarn-error.log")
}.toList())

// the outputs of this task include where we place the final files for use in the module, as well as the local
// temporary "dist" folders. Defining these outputs gives the build enough awareness to avoid running this
// task if it"s already been executed, the outputs are where they are expected, and there have been no changes to
// inputs.
outputs.files(fileTree(projectOutput))
}

// task to delete the dist folders
val deleteDistFolders by tasks.registering(Delete::class) {
delete(file("packages/gateway/dist/"))
}

tasks {
processResources {
dependsOn(webpack, yarnPackages)
}

clean {
// makes the "built in" clean task execute the deletion tasks
dependsOn(deleteDistFolders)
}
}


val deepClean by tasks.registering {
doLast {
delete(file("packages/gateway/node_modules"))
delete(file("packages/gateway/.gradle"))
delete(file(".gradle"))
delete(file("node_modules"))
}

dependsOn(project.tasks.named("clean"))
}

// make sure the gateway project doesn't process resources until the webpack task is done.
project(":gateway")?.tasks?.named("processResources")?.configure {
dependsOn(webpack)
}


sourceSets {
main {
output.dir(projectOutput, "builtBy" to listOf(webpack))
}
}

At this point all of the web sub-project requirements for this example should be completed. The next step is to define our Gateway hook scope so that it can properly retrieve the resources it needs for a simple status test page.

Defining the Gateway Hook

The Gateway hook is one of the three default hooks of an Ignition module where you will develop anything related to the Gateway webpages and webserver resources. Thanks to the Ignition Module Tools template, this process is fairly straightforward since you should already have all of the necessary classes and methods created with empty bodies. The following steps will provide you with the required code and commands for this example:

  1. Navigate to the Gateway hook’s build.gradle.kts file and locate the line:
    languageVersion.set(org.gradle.jvm.toolchain.JavaLanguageVersion.of(11))

  2. Replace it with the following:
    languageVersion.set(JavaLanguageVersion.of(17))

  3. On the same file add the following declaration to the dependencies list:
    modlImplementation(project(":web"))

Then navigate to the Gateway hook’s source file. Depending on your module ID, the name of the file will vary. In this example, it is called TestBuildGatewayHook.java where TestBuild is prepended to the filename GatewayHook.java during the Ignition Modules Tools project generation. Here you will add a few different lines of code to the java class’s body. On the top of the class body add the following code snippet:

private static final INamedTab TEST_STATUS_PAGE = new AbstractNamedTab(
"testbuild",
StatusCategories.OTHER,
"Test Page") {

@Override
public WebMarkupContainer getPanel(String panelId) {
return new BasicReactPanel(panelId, "/res/testbuild/js/TestBuild.js", "TestBuild");
}
};
note

For the new AbstractNamedTab object’s parameters, ensure that the first argument is using the module ID’s trailing keyword. For the new BasicReactPanel object’s parameters, there are three places that require your module ID’s trailing keyword:

"/res/{1}/js/{2}.js", "{3}"

Where {1} needs the keyword in all lowercase letters and {2} & {3} can be in camel case (ex: TestBuild).

Scroll down to the same public class’s methods and search for the following methods to apply these changes:

getMountedResourceFolder()

@Override
public Optional<String> getMountedResourceFolder() {
return Optional.of("mounted");
}

getMountPathAlias()

Here the string declaration inside of Optional.of(“”) depends on the module ID’s trailing keyword. Enter it in all lowercase letters:

@Override
public Optional<String> getMountPathAlias() {
return Optional.of("testbuild");
}

getStatusPanels()

And lastly, add the following method:

@Override
public List<? extends INamedTab> getStatusPanels() {
return Collections.singletonList(TEST_STATUS_PAGE);
}

Once this is defined, you should have everything necessary to run Gradle build and obtain your custom .modl file for installation with an Ignition Gateway.