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
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.
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:
Navigate to the Gateway hook’s
build.gradle.kts
file and locate the line:
languageVersion.set(org.gradle.jvm.toolchain.JavaLanguageVersion.of(11))
Replace it with the following:
languageVersion.set(JavaLanguageVersion.of(17))
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");
}
};
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.