Finished HTTP node creation and extensive node creation documentation in project

This commit is contained in:
Richard Osborne
2025-12-29 08:56:46 +01:00
parent fad9f1006d
commit 6fd59e83e6
13 changed files with 1008 additions and 247 deletions

View File

@@ -34,39 +34,39 @@ packages/noodl-runtime/src/nodes/std-library/
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
type: 'string', // See "Port Types" section below
displayName: 'Input Name',
group: 'General', // Group in property panel
group: 'General', // Group in property panel
default: 'default value'
},
doAction: {
@@ -75,7 +75,7 @@ var MyNode = {
group: 'Actions'
}
},
// REQUIRED: Define output ports
outputs: {
outputValue: {
@@ -94,7 +94,7 @@ var MyNode = {
group: 'Events'
}
},
// OPTIONAL: Methods to handle input changes
methods: {
setInputName: function (value) {
@@ -102,19 +102,19 @@ var MyNode = {
// 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') {
@@ -126,7 +126,7 @@ var MyNode = {
// REQUIRED: Export the node
module.exports = {
node: MyNode,
// OPTIONAL: Setup function for dynamic ports
setup: function (context, graphModel) {
// See "Dynamic Ports" section below
@@ -144,10 +144,10 @@ Add the node to the `registerNodes` function in `packages/noodl-runtime/noodl-ru
function registerNodes(noodlRuntime) {
[
// ... existing nodes ...
// Add your new node
require('./src/nodes/std-library/data/mynode'),
require('./src/nodes/std-library/data/mynode')
// ... more nodes ...
].forEach((node) => noodlRuntime.registerNode(node));
}
@@ -174,10 +174,10 @@ const coreNodes = [
// ... other subcategories ...
{
name: 'External Data',
items: ['net.noodl.MyNode', 'REST2'] // Add your node name here
items: ['net.noodl.MyNode', 'REST2'] // Add your node name here
}
]
},
}
// ... more categories ...
];
```
@@ -188,24 +188,24 @@ const coreNodes = [
### 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 |
| 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 |
| Type | Description |
| -------------------------------- | --------------------------------- |
| `{ name: 'enum', enums: [...] }` | Dropdown selection |
| `{ name: 'stringlist' }` | List of strings (comma-separated) |
| `{ name: 'number', min, max }` | Number with constraints |
### Example Enum Input
@@ -239,7 +239,7 @@ Dynamic ports are created at runtime based on configuration. This is useful when
```javascript
module.exports = {
node: MyNode,
setup: function (context, graphModel) {
// Only run in editor, not deployed
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
@@ -248,7 +248,7 @@ module.exports = {
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Always include base ports from node definition
// Add dynamic ports based on parameters
if (parameters.items) {
@@ -262,7 +262,7 @@ module.exports = {
});
});
}
// Send ports to editor
editorConnection.sendDynamicPorts(nodeId, ports);
}
@@ -348,7 +348,7 @@ 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 => {
@@ -394,6 +394,348 @@ getInspectInfo() {
---
## ⚠️ 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:**
```javascript
// ❌ 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.setInputValue` **completely overrides** `Node.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:**
```javascript
// ✅ 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:**
```javascript
// 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 `inputs` are NOT automatically merged
- The editor only shows dynamic ports, connections to static ports fail
**THE FIX:**
```javascript
// ✅ 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:**
```javascript
// 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 `registerInputIfNeeded` is only called for ports not in static `inputs`
- If there's no setter, the value is lost
**THE FIX:**
```javascript
// ✅ 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:**
```javascript
// ❌ 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 use `valueChangedToTrue`
- The runtime wraps signals with `EdgeTriggeredInput.createSetter()` which tracks state transitions
- Signals only fire on FALSE → TRUE transition
**THE FIX:**
```javascript
// ✅ 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 through `defineNode()`
- Nodes in `noodl-viewer-react/register-nodes.js` → Go through `defineNode()`
- Raw node object passed directly → Does NOT go through `defineNode()`
**WHY IT MATTERS:**
- `defineNode()` in `nodedefinition.js` wraps signal inputs with `EdgeTriggeredInput.createSetter()`
- Without `defineNode()`, signals are registered but never fire
- The `{node, setup}` export format automatically calls `defineNode()`
**THE FIX:**
```javascript
// ✅ 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:
```javascript
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
@@ -416,9 +758,13 @@ getInspectInfo() {
### Signal Not Firing
**Cause:** Method name doesn't match pattern `inputName + 'Trigger'`.
**Cause #1:** Method name pattern wrong - use `valueChangedToTrue`, not `inputName + 'Trigger'`.
**Fix:** Ensure signal handler method is named correctly (e.g., `fetchTrigger` for input `fetch`).
**Cause #2:** Custom `setInputValue` overriding base - see GOTCHA #1.
**Cause #3:** Signal not in dynamic ports - see GOTCHA #2.
**Fix:** Review ALL gotchas above!
---
@@ -437,14 +783,14 @@ getInspectInfo() {
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 |
| 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*
_Last Updated: December 2024_

View File

@@ -852,6 +852,145 @@ const handleTreeMouseUp = useCallback(() => {
---
## 🔥 CRITICAL: Runtime Node Creation - The Unholy Trinity of Silent Failures (Dec 2025)
### The HTTP Node Debugging Saga: 3+ Hours of Silent Failures
**Context**: Creating an HTTP node (httpnode.js) as a modern, no-code-friendly alternative to the script-based REST node. Everything looked correct but nothing worked. Signals never fired, config values never set, and no errors anywhere.
**The Problems Found** (each took significant debugging time):
#### Problem 1: Signal Input Using `set` Instead of `valueChangedToTrue`
```javascript
// ❌ WHAT I WROTE (never triggers)
inputs: {
fetch: {
type: 'signal',
set: function(value) {
this.scheduleFetch(); // ☠️ Never called
}
}
}
// ✅ WHAT IT SHOULD BE
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() {
this.scheduleFetch(); // ✓ Works!
}
}
}
```
**Why**: Signals use `EdgeTriggeredInput.createSetter()` which wraps the callback and only calls `valueChangedToTrue` when value transitions from falsy to truthy. The `set` callback is never used.
#### Problem 2: Custom `setInputValue` Overriding Base Method
```javascript
// ❌ WHAT I WROTE (breaks ALL inputs including signals)
prototypeExtensions: {
setInputValue: function(name, value) {
this._internal.inputValues[name] = value; // ☠️ Overrides Node.prototype.setInputValue
}
}
// ✅ WHAT IT SHOULD BE
prototypeExtensions: {
_storeInputValue: function(name, value) { // Different name!
this._internal.inputValues[name] = value;
}
}
```
**Why**: `prototypeExtensions` methods are merged into node prototype. Defining `setInputValue` completely replaces the base implementation, which handles signal detection, input.set() calls, and event emission.
#### Problem 3: Dynamic Ports Replacing Static Ports
```javascript
// ❌ WHAT I WROTE (static ports disappear)
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Only add dynamic header/query param ports...
if (parameters.headers) { /* add header ports */ }
editorConnection.sendDynamicPorts(nodeId, ports); // Static inputs GONE!
}
// ✅ WHAT IT SHOULD BE
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
// Re-add ALL static inputs
{ name: 'url', displayName: 'URL', type: 'string', plug: 'input', group: 'Request' },
{ name: 'fetch', displayName: 'Fetch', type: 'signal', plug: 'input', group: 'Actions' },
{ name: 'cancel', displayName: 'Cancel', type: 'signal', plug: 'input', group: 'Actions' },
// Then dynamic ports...
];
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
**Why**: `sendDynamicPorts` REPLACES all ports, not merges. The `inputs` object in node definition is only for default setup - once dynamic ports are sent, they're the only ports.
#### Problem 4: Config Inputs (StringList/Enum) Need Explicit Registration
```javascript
// ❌ MISSING (config values never reach setters)
// StringList inputs like "headers", "queryParams" appear in editor but their
// values never reach the node because there's no registered input handler
// ✅ WHAT IT NEEDS
registerInputIfNeeded: function(name) {
if (this.hasInput(name)) return;
const configSetters = {
'method': this.setMethod.bind(this),
'headers': this.setHeaders.bind(this),
'queryParams': this.setQueryParams.bind(this),
'bodyType': this.setBodyType.bind(this),
// ... all config inputs
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Dynamic inputs (header-X, query-Y, etc.)
if (name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
```
**Why**: Inputs defined in the `inputs` object get registered automatically. Dynamic ports don't - they need `registerInputIfNeeded` to create runtime handlers.
**Why This Was So Hard to Debug**:
1. **No errors** - Everything appeared to work, logs said "success", but nothing happened
2. **Partial success** - Some things worked (node appeared in palette) making it seem close
3. **Multiple bugs** - Each fix revealed the next bug, each taking time to diagnose
4. **No TypeScript** - Runtime code is JS, no compile-time checking
5. **Unfamiliar patterns** - `valueChangedToTrue`, `registerInputIfNeeded`, etc. aren't obvious
**Time Lost**: 3+ hours debugging what should have been a straightforward node
**Prevention**:
- Created `dev-docs/reference/LEARNINGS-NODE-CREATION.md` with Critical Gotchas section
- Added Node Creation Checklist to `.clinerules` Section 14
- These gotchas are now documented with THE BUG / WHY IT BREAKS / THE FIX patterns
**Location**:
- `packages/noodl-runtime/src/nodes/std-library/data/httpnode.js` (fixed)
- `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (new documentation)
**Keywords**: node creation, signal, valueChangedToTrue, setInputValue, prototypeExtensions, sendDynamicPorts, registerInputIfNeeded, stringlist, enum, config inputs, HTTP node, runtime
---
## 🔥 CRITICAL: PopupLayer.dragCompleted() - Not endDrag() (Dec 2025)
### Wrong Method Name Causes TypeError