20 KiB
Creating Nodes in OpenNoodl
This guide documents the complete process for creating new nodes in the OpenNoodl runtime, based on learnings from building the HTTP Request node.
Overview
Nodes in Noodl are defined in the noodl-runtime package and need to be:
- Created - Define the node in a
.jsfile - Registered - Add to
noodl-runtime.js - Indexed - Add to
nodelibraryexport.jsfor Node Picker visibility
Step 1: Create the Node File
Create a new file in the appropriate category folder:
packages/noodl-runtime/src/nodes/std-library/
├── data/ # Data nodes (REST, HTTP, collections)
├── variables/ # Variable nodes (string, number, boolean)
├── user/ # User authentication nodes
└── *.js # General utility nodes
Basic Node Structure
'use strict';
var MyNode = {
// REQUIRED: Unique identifier for the node
name: 'net.noodl.MyNode',
// REQUIRED: Display name in Node Picker and canvas
displayNodeName: 'My Node',
// OPTIONAL: Documentation URL
docs: 'https://docs.noodl.net/nodes/category/my-node',
// REQUIRED: Category for organization (Data, Visual, Logic, etc.)
category: 'Data',
// OPTIONAL: Node color theme
// Options: 'data' (green), 'visual' (blue), 'component' (purple), 'javascript' (pink), 'default' (gray)
color: 'data',
// OPTIONAL: Search keywords for Node Picker
searchTags: ['my', 'node', 'custom', 'example'],
// OPTIONAL: Called when node instance is created
initialize: function () {
this._internal.myData = {};
},
// OPTIONAL: Data shown in debug inspector
getInspectInfo() {
return this._internal.inspectData;
},
// REQUIRED: Define input ports
inputs: {
inputName: {
type: 'string', // See "Port Types" section below
displayName: 'Input Name',
group: 'General', // Group in property panel
default: 'default value'
},
doAction: {
type: 'signal',
displayName: 'Do Action',
group: 'Actions'
}
},
// REQUIRED: Define output ports
outputs: {
outputValue: {
type: 'string',
displayName: 'Output Value',
group: 'Results'
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
// OPTIONAL: Methods to handle input changes
methods: {
setInputName: function (value) {
this._internal.inputName = value;
// Optionally trigger output update
this.flagOutputDirty('outputValue');
},
// Signal handler - name must match input name with 'Trigger' suffix
doActionTrigger: function () {
// Perform the action
const result = this.processInput(this._internal.inputName);
this._internal.outputValue = result;
// Update outputs
this.flagOutputDirty('outputValue');
this.sendSignalOnOutput('success');
}
},
// OPTIONAL: Return output values
getOutputValue: function (name) {
if (name === 'outputValue') {
return this._internal.outputValue;
}
}
};
// REQUIRED: Export the node
module.exports = {
node: MyNode,
// OPTIONAL: Setup function for dynamic ports
setup: function (context, graphModel) {
// See "Dynamic Ports" section below
}
};
Step 2: Register the Node
Add the node to the registerNodes function in packages/noodl-runtime/noodl-runtime.js:
function registerNodes(noodlRuntime) {
[
// ... existing nodes ...
// Add your new node
require('./src/nodes/std-library/data/mynode')
// ... more nodes ...
].forEach((node) => noodlRuntime.registerNode(node));
}
Important: The order in this array doesn't matter, but group related nodes together for readability.
Step 3: Add to Node Picker Index
CRITICAL: This step is often forgotten! Without it, the node won't appear in the Node Picker.
Edit packages/noodl-runtime/src/nodelibraryexport.js and add your node to the appropriate category in the coreNodes array:
const coreNodes = [
// ... other categories ...
{
name: 'Read & Write Data',
description: 'Arrays, objects, cloud data',
type: 'data',
subCategories: [
// ... other subcategories ...
{
name: 'External Data',
items: ['net.noodl.MyNode', 'REST2'] // Add your node name here
}
]
}
// ... more categories ...
];
Port Types
Common Input/Output Types
| Type | Description | Example Use |
|---|---|---|
string |
Text value | URLs, names, content |
number |
Numeric value | Counts, sizes, coordinates |
boolean |
True/false | Toggles, conditions |
signal |
Trigger without data | Action buttons, events |
object |
JSON object | API responses, data structures |
array |
List of items | Collections, results |
color |
Color value | Styling |
* |
Any type | Generic ports |
Input-Specific Types
| Type | Description |
|---|---|
{ name: 'enum', enums: [...] } |
Dropdown selection |
{ name: 'stringlist' } |
List of strings (comma-separated) |
{ name: 'number', min, max } |
Number with constraints |
Example Enum Input
inputs: {
method: {
type: {
name: 'enum',
enums: [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' }
]
},
displayName: 'Method',
default: 'GET',
group: 'Request'
}
}
Dynamic Ports
Dynamic ports are created at runtime based on configuration. This is useful when the number or names of ports depend on user settings.
Setup Function Pattern
module.exports = {
node: MyNode,
setup: function (context, graphModel) {
// Only run in editor, not deployed
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Always include base ports from node definition
// Add dynamic ports based on parameters
if (parameters.items) {
parameters.items.split(',').forEach((item) => {
ports.push({
name: 'item-' + item.trim(),
displayName: item.trim(),
type: 'string',
plug: 'input',
group: 'Items'
});
});
}
// Send ports to editor
editorConnection.sendDynamicPorts(nodeId, ports);
}
function managePortsForNode(node) {
updatePorts(node.id, node.parameters || {}, context.editorConnection);
node.on('parameterUpdated', function (event) {
if (event.name === 'items') {
updatePorts(node.id, node.parameters, context.editorConnection);
}
});
}
// Listen for graph import completion
graphModel.on('editorImportComplete', () => {
// Listen for new nodes of this type
graphModel.on('nodeAdded.net.noodl.MyNode', function (node) {
managePortsForNode(node);
});
// Handle existing nodes
for (const node of graphModel.getNodesWithType('net.noodl.MyNode')) {
managePortsForNode(node);
}
});
}
};
Handling Signals
Signals are trigger-based ports (no data, just an event).
Receiving Signals (Input)
// In methods object
methods: {
// Pattern: inputName + 'Trigger'
fetchTrigger: function () {
// Called when 'fetch' signal is triggered
this.doFetch();
}
}
Sending Signals (Output)
// Send a signal pulse
this.sendSignalOnOutput('success');
this.sendSignalOnOutput('failure');
Updating Outputs
When an output value changes, you must flag it as dirty:
// Flag a single output
this.flagOutputDirty('outputValue');
// Flag multiple outputs
this.flagOutputDirty('response');
this.flagOutputDirty('statusCode');
// Then send signal if needed
this.sendSignalOnOutput('complete');
Async Operations
For asynchronous operations (API calls, file I/O), use standard async patterns:
methods: {
fetchTrigger: function () {
const self = this;
fetch(this._internal.url)
.then(response => response.json())
.then(data => {
self._internal.response = data;
self.flagOutputDirty('response');
self.sendSignalOnOutput('success');
})
.catch(error => {
self._internal.error = error.message;
self.flagOutputDirty('error');
self.sendSignalOnOutput('failure');
});
}
}
Debug Inspector
Provide data for the debug inspector popup:
getInspectInfo() {
// Return an array of objects with type and value
return [
{ type: 'text', value: 'Status: ' + this._internal.status },
{ type: 'value', value: this._internal.response }
];
}
Testing Your Node
- Start the dev server:
npm run dev - Open the Node Picker (click in the node graph)
- Search for your node by name or search tags
- Navigate to the category to verify placement
- Add the node and test inputs/outputs
- Check console for any errors
⚠️ CRITICAL GOTCHAS - READ BEFORE CREATING NODES
These issues will cause silent failures with NO error messages. They were discovered during the HTTP node debugging session (December 2024) and cost hours of debugging time.
🔴 GOTCHA #1: Never Override setInputValue in prototypeExtensions
THE BUG:
// ❌ DEADLY - This silently breaks ALL signal inputs
prototypeExtensions: {
setInputValue: function (name, value) {
this._internal.inputValues[name] = value; // Signal setters NEVER called!
}
}
WHY IT BREAKS:
prototypeExtensions.setInputValuecompletely overridesNode.prototype.setInputValue- The base method contains
input.set.call(this, value)which triggers signal callbacks - Without it, signals never fire - NO errors, just silent failure
THE FIX:
// ✅ SAFE - Use a different method name for storing dynamic values
prototypeExtensions: {
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value;
},
registerInputIfNeeded: function (name) {
// Register dynamic inputs with _storeInputValue, not setInputValue
if (name.startsWith('body-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
}
🔴 GOTCHA #2: Dynamic Ports REPLACE Static Ports
THE BUG:
// Node has static inputs defined in inputs: {}
inputs: {
url: { type: 'string', set: function(v) {...} },
fetch: { type: 'signal', valueChangedToTrue: function() {...} }
},
// But setup function only sends dynamic ports
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
{ name: 'headers', ... },
{ name: 'queryParams', ... }
];
// ❌ MISSING url, fetch - they won't appear in editor!
editorConnection.sendDynamicPorts(nodeId, ports);
}
WHY IT BREAKS:
sendDynamicPorts()tells the editor "these are ALL the ports for this node"- Static
inputsare NOT automatically merged - The editor only shows dynamic ports, connections to static ports fail
THE FIX:
// ✅ SAFE - Include ALL ports in dynamic ports array
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Dynamic configuration ports
ports.push({ name: 'headers', type: {...}, plug: 'input' });
ports.push({ name: 'queryParams', type: {...}, plug: 'input' });
// MUST include static ports too!
ports.push({ name: 'url', type: 'string', plug: 'input', group: 'Request' });
ports.push({ name: 'fetch', type: 'signal', plug: 'input', group: 'Actions' });
ports.push({ name: 'cancel', type: 'signal', plug: 'input', group: 'Actions' });
// Include outputs too
ports.push({ name: 'success', type: 'signal', plug: 'output', group: 'Events' });
editorConnection.sendDynamicPorts(nodeId, ports);
}
🔴 GOTCHA #3: Configuration Inputs Need Explicit Registration
THE BUG:
// Dynamic ports send method, bodyType, etc. to editor
ports.push({ name: 'method', type: { name: 'enum', ... } });
ports.push({ name: 'bodyType', type: { name: 'enum', ... } });
// ❌ Values never reach runtime - no setter registered!
// User selects POST in editor, but doFetch() always uses GET
doFetch: function() {
const method = this._internal.method || 'GET'; // Always undefined!
}
WHY IT BREAKS:
- Dynamic port values are sent to runtime as input values via
setInputValue - But
registerInputIfNeededis only called for ports not in staticinputs - If there's no setter, the value is lost
THE FIX:
// ✅ SAFE - Register setters for all config inputs
prototypeExtensions: {
// Setter methods
setMethod: function (value) { this._internal.method = value || 'GET'; },
setBodyType: function (value) { this._internal.bodyType = value; },
setHeaders: function (value) { this._internal.headers = value || ''; },
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Configuration inputs - bind to their setters
const configSetters = {
method: this.setMethod.bind(this),
bodyType: this.setBodyType.bind(this),
headers: this.setHeaders.bind(this),
timeout: this.setTimeout.bind(this)
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Dynamic inputs for prefixed ports
if (name.startsWith('body-') || name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
}
🔴 GOTCHA #4: Signal Inputs Use valueChangedToTrue, Not set
THE BUG:
// ❌ WRONG - This won't trigger on signal
inputs: {
fetch: {
type: 'signal',
set: function() {
this.doFetch(); // Never called!
}
}
}
WHY IT BREAKS:
- Signal inputs don't use
set- they usevalueChangedToTrue - The runtime wraps signals with
EdgeTriggeredInput.createSetter()which tracks state transitions - Signals only fire on FALSE → TRUE transition
THE FIX:
// ✅ CORRECT - Use valueChangedToTrue for signals
inputs: {
fetch: {
type: 'signal',
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleFetch();
}
},
cancel: {
type: 'signal',
displayName: 'Cancel',
group: 'Actions',
valueChangedToTrue: function () {
this.cancelFetch();
}
}
}
🔴 GOTCHA #5: Node Registration Path Matters
THE BUG:
- Nodes in
noodl-runtime/noodl-runtime.js→ Go throughdefineNode() - Nodes in
noodl-viewer-react/register-nodes.js→ Go throughdefineNode() - Raw node object passed directly → Does NOT go through
defineNode()
WHY IT MATTERS:
defineNode()innodedefinition.jswraps signal inputs withEdgeTriggeredInput.createSetter()- Without
defineNode(), signals are registered but never fire - The
{node, setup}export format automatically callsdefineNode()
THE FIX:
// ✅ CORRECT - Always export with {node, setup} format
module.exports = {
node: MyNode, // Goes through defineNode()
setup: function (context, graphModel) {
// Dynamic port setup
}
};
// ✅ ALSO CORRECT - Call defineNode explicitly
const NodeDefinition = require('./nodedefinition');
module.exports = NodeDefinition.defineNode(MyNode);
Complete Working Pattern (HTTP Node Reference)
Here's the proven pattern from the HTTP node that handles all gotchas:
var MyNode = {
name: 'net.noodl.MyNode',
displayNodeName: 'My Node',
category: 'Data',
color: 'data',
initialize: function () {
this._internal.inputValues = {}; // For dynamic input storage
this._internal.method = 'GET'; // Config defaults
},
// Static inputs - signals and essential ports
inputs: {
url: {
type: 'string',
set: function (value) { this._internal.url = value; }
},
fetch: {
type: 'signal',
valueChangedToTrue: function () { this.scheduleFetch(); }
}
},
outputs: {
response: { type: '*', getter: function() { return this._internal.response; } },
success: { type: 'signal' },
failure: { type: 'signal' }
},
prototypeExtensions: {
// Store dynamic values WITHOUT overriding setInputValue
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value;
},
// Configuration setters
setMethod: function (value) { this._internal.method = value || 'GET'; },
setHeaders: function (value) { this._internal.headers = value || ''; },
// Register ALL dynamic inputs
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Config inputs
const configSetters = {
method: this.setMethod.bind(this),
headers: this.setHeaders.bind(this)
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Prefixed dynamic inputs
if (name.startsWith('header-') || name.startsWith('body-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
},
scheduleFetch: function () {
this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this));
},
doFetch: function () {
const method = this._internal.method; // Now correctly captured!
// ... fetch implementation
}
}
};
module.exports = {
node: MyNode,
setup: function (context, graphModel) {
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Config ports
ports.push({ name: 'method', type: { name: 'enum', enums: [...] }, plug: 'input' });
ports.push({ name: 'headers', type: { name: 'stringlist' }, plug: 'input' });
// MUST include static ports!
ports.push({ name: 'url', type: 'string', plug: 'input' });
ports.push({ name: 'fetch', type: 'signal', plug: 'input' });
// Outputs
ports.push({ name: 'response', type: '*', plug: 'output' });
ports.push({ name: 'success', type: 'signal', plug: 'output' });
editorConnection.sendDynamicPorts(nodeId, ports);
}
// ... rest of setup
}
};
Common Issues
Node Not Appearing in Node Picker
Cause: Node not added to nodelibraryexport.js coreNodes array.
Fix: Add the node name to the appropriate subcategory items array.
"Cannot read property of undefined" Errors
Cause: Accessing this._internal before initialize() runs.
Fix: Always check for undefined or initialize values in initialize().
Outputs Not Updating
Cause: Forgot to call flagOutputDirty().
Fix: Call this.flagOutputDirty('portName') after setting internal value.
Signal Not Firing
Cause #1: Method name pattern wrong - use valueChangedToTrue, not inputName + 'Trigger'.
Cause #2: Custom setInputValue overriding base - see GOTCHA #1.
Cause #3: Signal not in dynamic ports - see GOTCHA #2.
Fix: Review ALL gotchas above!
File Checklist for New Nodes
- Create node file in
packages/noodl-runtime/src/nodes/std-library/[category]/ - Add
require()topackages/noodl-runtime/noodl-runtime.js - Add node name to
packages/noodl-runtime/src/nodelibraryexport.jscoreNodes - Test node appears in Node Picker
- Test all inputs/outputs work correctly
- Verify debug inspector shows useful info
Reference Files
When creating new nodes, reference these existing nodes for patterns:
| Node | File | Good Example Of |
|---|---|---|
| REST | data/restnode.js |
Full-featured data node with scripts |
| HTTP | data/httpnode.js |
Dynamic ports, configuration |
| String | variables/string.js |
Simple variable node |
| Counter | counter.js |
Stateful logic node |
| Condition | condition.js |
Boolean logic |
Last Updated: December 2024