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

@@ -927,3 +927,155 @@ If you don't see the log, the subscription isn't working.
_Last Updated: December 2025_ _Last Updated: December 2025_
``` ```
---
## 14. Node Creation Checklist
> **🚨 CRITICAL:** Before creating or modifying runtime nodes, read `dev-docs/reference/LEARNINGS-NODE-CREATION.md`
Creating nodes in OpenNoodl is deceptively tricky. This checklist prevents the most common (and hardest to debug) issues.
### 14.1 Pre-Flight Checklist
Before writing any node code:
- [ ] Read `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (especially the CRITICAL GOTCHAS section)
- [ ] Study an existing working node of similar complexity (e.g., `restnode.js` for data nodes)
- [ ] Understand the difference between `inputs` (static) vs `prototypeExtensions` (instance methods)
- [ ] Know where your node should be registered (noodl-runtime vs noodl-viewer-react)
### 14.2 Input Handler Rules
```javascript
// ✅ CORRECT: Signal inputs use valueChangedToTrue
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() {
this.scheduleFetch();
}
}
}
// ❌ WRONG: Signal inputs with set() - NEVER TRIGGERS
inputs: {
fetch: {
type: 'signal',
set: function(value) { // ☠️ Never called for signals
this.scheduleFetch();
}
}
}
```
### 14.3 Never Override setInputValue
```javascript
// ❌ BREAKS EVERYTHING - Never define setInputValue in prototypeExtensions
prototypeExtensions: {
setInputValue: function(name, value) { // ☠️ Overrides base - signals stop working
// ...
}
}
// ✅ Use a different name for custom storage
prototypeExtensions: {
_storeInputValue: function(name, value) { // ✅ Doesn't override anything
this._internal.inputValues[name] = value;
}
}
```
### 14.4 Dynamic Ports Must Include Static Ports
```javascript
// ❌ WRONG - Static ports disappear
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Only adds dynamic ports...
editorConnection.sendDynamicPorts(nodeId, ports); // Static inputs gone!
}
// ✅ CORRECT - Include all ports
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
// Re-add static inputs
{ name: 'url', displayName: 'URL', type: 'string', plug: 'input', group: 'Request' },
{ name: 'fetch', displayName: 'Fetch', type: 'signal', plug: 'input', group: 'Actions' },
// Then add dynamic ports...
];
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
### 14.5 Register Config Inputs Explicitly
```javascript
// Config inputs (from stringlist editors) need explicit registration
registerInputIfNeeded: function(name) {
if (this.hasInput(name)) return;
// Map config names to their setters
const configSetters = {
'method': this.setMethod.bind(this),
'headers': this.setHeaders.bind(this),
'queryParams': this.setQueryParams.bind(this)
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Handle prefixed dynamic inputs
if (name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
```
### 14.6 Export Format Matters
```javascript
// ✅ CORRECT: Export with setup function
module.exports = {
node: MyNode,
setup: function (context, graphModel) {
// Port management goes here
}
};
// ❌ WRONG: Direct export (setup never runs)
module.exports = MyNode;
```
### 14.7 Post-Creation Verification
After creating a node:
1. **Check ports appear**: All static AND dynamic inputs/outputs visible in editor?
2. **Check signals work**: Add console.log in `valueChangedToTrue` - does it print?
3. **Check config inputs work**: Change dropdown/stringlist values - does setter get called?
4. **Clear caches if needed**: `npm run clean:all` and restart
### 14.8 Quick Reference
| Input Type | Handler | Callback Format |
| ---------------------------- | --------------------------- | --------------------------- |
| Signal | `valueChangedToTrue` | `function() { ... }` |
| Value (string, number, etc.) | `set` | `function(value) { ... }` |
| Enum (dropdown) | `set` | `function(value) { ... }` |
| StringList (config) | Needs explicit registration | Via `registerInputIfNeeded` |
### 14.9 Where to Find Examples
| Pattern | Example File |
| ------------------------------------ | ---------------------------------------------------------------- |
| Complex data node with dynamic ports | `noodl-runtime/src/nodes/std-library/data/restnode.js` |
| HTTP node (fixed, working) | `noodl-runtime/src/nodes/std-library/data/httpnode.js` |
| Simple value node | `noodl-runtime/src/nodes/std-library/variables/numbernode.js` |
| Signal-based node | `noodl-runtime/src/nodes/std-library/timer.js` (in viewer-react) |
---

View File

@@ -34,39 +34,39 @@ packages/noodl-runtime/src/nodes/std-library/
var MyNode = { var MyNode = {
// REQUIRED: Unique identifier for the node // REQUIRED: Unique identifier for the node
name: 'net.noodl.MyNode', name: 'net.noodl.MyNode',
// REQUIRED: Display name in Node Picker and canvas // REQUIRED: Display name in Node Picker and canvas
displayNodeName: 'My Node', displayNodeName: 'My Node',
// OPTIONAL: Documentation URL // OPTIONAL: Documentation URL
docs: 'https://docs.noodl.net/nodes/category/my-node', docs: 'https://docs.noodl.net/nodes/category/my-node',
// REQUIRED: Category for organization (Data, Visual, Logic, etc.) // REQUIRED: Category for organization (Data, Visual, Logic, etc.)
category: 'Data', category: 'Data',
// OPTIONAL: Node color theme // OPTIONAL: Node color theme
// Options: 'data' (green), 'visual' (blue), 'component' (purple), 'javascript' (pink), 'default' (gray) // Options: 'data' (green), 'visual' (blue), 'component' (purple), 'javascript' (pink), 'default' (gray)
color: 'data', color: 'data',
// OPTIONAL: Search keywords for Node Picker // OPTIONAL: Search keywords for Node Picker
searchTags: ['my', 'node', 'custom', 'example'], searchTags: ['my', 'node', 'custom', 'example'],
// OPTIONAL: Called when node instance is created // OPTIONAL: Called when node instance is created
initialize: function () { initialize: function () {
this._internal.myData = {}; this._internal.myData = {};
}, },
// OPTIONAL: Data shown in debug inspector // OPTIONAL: Data shown in debug inspector
getInspectInfo() { getInspectInfo() {
return this._internal.inspectData; return this._internal.inspectData;
}, },
// REQUIRED: Define input ports // REQUIRED: Define input ports
inputs: { inputs: {
inputName: { inputName: {
type: 'string', // See "Port Types" section below type: 'string', // See "Port Types" section below
displayName: 'Input Name', displayName: 'Input Name',
group: 'General', // Group in property panel group: 'General', // Group in property panel
default: 'default value' default: 'default value'
}, },
doAction: { doAction: {
@@ -75,7 +75,7 @@ var MyNode = {
group: 'Actions' group: 'Actions'
} }
}, },
// REQUIRED: Define output ports // REQUIRED: Define output ports
outputs: { outputs: {
outputValue: { outputValue: {
@@ -94,7 +94,7 @@ var MyNode = {
group: 'Events' group: 'Events'
} }
}, },
// OPTIONAL: Methods to handle input changes // OPTIONAL: Methods to handle input changes
methods: { methods: {
setInputName: function (value) { setInputName: function (value) {
@@ -102,19 +102,19 @@ var MyNode = {
// Optionally trigger output update // Optionally trigger output update
this.flagOutputDirty('outputValue'); this.flagOutputDirty('outputValue');
}, },
// Signal handler - name must match input name with 'Trigger' suffix // Signal handler - name must match input name with 'Trigger' suffix
doActionTrigger: function () { doActionTrigger: function () {
// Perform the action // Perform the action
const result = this.processInput(this._internal.inputName); const result = this.processInput(this._internal.inputName);
this._internal.outputValue = result; this._internal.outputValue = result;
// Update outputs // Update outputs
this.flagOutputDirty('outputValue'); this.flagOutputDirty('outputValue');
this.sendSignalOnOutput('success'); this.sendSignalOnOutput('success');
} }
}, },
// OPTIONAL: Return output values // OPTIONAL: Return output values
getOutputValue: function (name) { getOutputValue: function (name) {
if (name === 'outputValue') { if (name === 'outputValue') {
@@ -126,7 +126,7 @@ var MyNode = {
// REQUIRED: Export the node // REQUIRED: Export the node
module.exports = { module.exports = {
node: MyNode, node: MyNode,
// OPTIONAL: Setup function for dynamic ports // OPTIONAL: Setup function for dynamic ports
setup: function (context, graphModel) { setup: function (context, graphModel) {
// See "Dynamic Ports" section below // 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) { function registerNodes(noodlRuntime) {
[ [
// ... existing nodes ... // ... existing nodes ...
// Add your new node // Add your new node
require('./src/nodes/std-library/data/mynode'), require('./src/nodes/std-library/data/mynode')
// ... more nodes ... // ... more nodes ...
].forEach((node) => noodlRuntime.registerNode(node)); ].forEach((node) => noodlRuntime.registerNode(node));
} }
@@ -174,10 +174,10 @@ const coreNodes = [
// ... other subcategories ... // ... other subcategories ...
{ {
name: 'External Data', 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 ... // ... more categories ...
]; ];
``` ```
@@ -188,24 +188,24 @@ const coreNodes = [
### Common Input/Output Types ### Common Input/Output Types
| Type | Description | Example Use | | Type | Description | Example Use |
|------|-------------|-------------| | --------- | -------------------- | ------------------------------ |
| `string` | Text value | URLs, names, content | | `string` | Text value | URLs, names, content |
| `number` | Numeric value | Counts, sizes, coordinates | | `number` | Numeric value | Counts, sizes, coordinates |
| `boolean` | True/false | Toggles, conditions | | `boolean` | True/false | Toggles, conditions |
| `signal` | Trigger without data | Action buttons, events | | `signal` | Trigger without data | Action buttons, events |
| `object` | JSON object | API responses, data structures | | `object` | JSON object | API responses, data structures |
| `array` | List of items | Collections, results | | `array` | List of items | Collections, results |
| `color` | Color value | Styling | | `color` | Color value | Styling |
| `*` | Any type | Generic ports | | `*` | Any type | Generic ports |
### Input-Specific Types ### Input-Specific Types
| Type | Description | | Type | Description |
|------|-------------| | -------------------------------- | --------------------------------- |
| `{ name: 'enum', enums: [...] }` | Dropdown selection | | `{ name: 'enum', enums: [...] }` | Dropdown selection |
| `{ name: 'stringlist' }` | List of strings (comma-separated) | | `{ name: 'stringlist' }` | List of strings (comma-separated) |
| `{ name: 'number', min, max }` | Number with constraints | | `{ name: 'number', min, max }` | Number with constraints |
### Example Enum Input ### Example Enum Input
@@ -239,7 +239,7 @@ Dynamic ports are created at runtime based on configuration. This is useful when
```javascript ```javascript
module.exports = { module.exports = {
node: MyNode, node: MyNode,
setup: function (context, graphModel) { setup: function (context, graphModel) {
// Only run in editor, not deployed // Only run in editor, not deployed
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) { if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
@@ -248,7 +248,7 @@ module.exports = {
function updatePorts(nodeId, parameters, editorConnection) { function updatePorts(nodeId, parameters, editorConnection) {
const ports = []; const ports = [];
// Always include base ports from node definition // Always include base ports from node definition
// Add dynamic ports based on parameters // Add dynamic ports based on parameters
if (parameters.items) { if (parameters.items) {
@@ -262,7 +262,7 @@ module.exports = {
}); });
}); });
} }
// Send ports to editor // Send ports to editor
editorConnection.sendDynamicPorts(nodeId, ports); editorConnection.sendDynamicPorts(nodeId, ports);
} }
@@ -348,7 +348,7 @@ For asynchronous operations (API calls, file I/O), use standard async patterns:
methods: { methods: {
fetchTrigger: function () { fetchTrigger: function () {
const self = this; const self = this;
fetch(this._internal.url) fetch(this._internal.url)
.then(response => response.json()) .then(response => response.json())
.then(data => { .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 ## Common Issues
### Node Not Appearing in Node Picker ### Node Not Appearing in Node Picker
@@ -416,9 +758,13 @@ getInspectInfo() {
### Signal Not Firing ### 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: When creating new nodes, reference these existing nodes for patterns:
| Node | File | Good Example Of | | Node | File | Good Example Of |
|------|------|-----------------| | --------- | --------------------- | ------------------------------------ |
| REST | `data/restnode.js` | Full-featured data node with scripts | | REST | `data/restnode.js` | Full-featured data node with scripts |
| HTTP | `data/httpnode.js` | Dynamic ports, configuration | | HTTP | `data/httpnode.js` | Dynamic ports, configuration |
| String | `variables/string.js` | Simple variable node | | String | `variables/string.js` | Simple variable node |
| Counter | `counter.js` | Stateful logic node | | Counter | `counter.js` | Stateful logic node |
| Condition | `condition.js` | Boolean logic | | 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) ## 🔥 CRITICAL: PopupLayer.dragCompleted() - Not endDrag() (Dec 2025)
### Wrong Method Name Causes TypeError ### Wrong Method Name Causes TypeError

View File

@@ -116,10 +116,14 @@ export function ComponentItem({
// If this item is a valid drop target, execute the drop // If this item is a valid drop target, execute the drop
if (isDropTarget && onDrop) { if (isDropTarget && onDrop) {
e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback) e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback)
// End drag IMMEDIATELY at event handler level (before action chain)
// This matches the working root drop pattern
PopupLayer.instance.dragCompleted();
const node: TreeNode = { type: 'component', data: component }; const node: TreeNode = { type: 'component', data: component };
onDrop(node); onDrop(node);
setIsDropTarget(false); setIsDropTarget(false);
// Note: dragCompleted() is called by handleDropOn - don't call it here
} }
}, },
[isDropTarget, component, onDrop] [isDropTarget, component, onDrop]

View File

@@ -111,10 +111,14 @@ export function FolderItem({
// If this folder is a valid drop target, execute the drop // If this folder is a valid drop target, execute the drop
if (isDropTarget && onDrop) { if (isDropTarget && onDrop) {
e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback) e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback)
// End drag IMMEDIATELY at event handler level (before action chain)
// This matches the working root drop pattern
PopupLayer.instance.dragCompleted();
const node: TreeNode = { type: 'folder', data: folder }; const node: TreeNode = { type: 'folder', data: folder };
onDrop(node); onDrop(node);
setIsDropTarget(false); setIsDropTarget(false);
// Note: dragCompleted() is called by handleDropOn - don't call it here
} }
}, },
[isDropTarget, folder, onDrop] [isDropTarget, folder, onDrop]

View File

@@ -523,15 +523,12 @@ export function useComponentActions() {
// Check for naming conflicts // Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) { if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists in that folder`); alert(`Component "${newName}" already exists in that folder`);
PopupLayer.instance.dragCompleted();
return; return;
} }
const oldName = component.name; const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render // Note: dragCompleted() now called by ComponentItem.handleMouseUp before this action
// This prevents the drag state from persisting across the tree rebuild
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo( UndoQueue.instance.pushAndDo(
new UndoActionGroup({ new UndoActionGroup({
@@ -554,7 +551,6 @@ export function useComponentActions() {
// Prevent moving folder into itself // Prevent moving folder into itself
if (targetPath.startsWith(sourcePath + '/') || targetPath === sourcePath) { if (targetPath.startsWith(sourcePath + '/') || targetPath === sourcePath) {
alert('Cannot move folder into itself'); alert('Cannot move folder into itself');
PopupLayer.instance.dragCompleted();
return; return;
} }
@@ -565,7 +561,6 @@ export function useComponentActions() {
if (!componentsToMove || componentsToMove.length === 0) { if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move'); console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
return; return;
} }
@@ -584,8 +579,7 @@ export function useComponentActions() {
renames.push({ component: comp, oldName: comp.name, newName }); renames.push({ component: comp, oldName: comp.name, newName });
}); });
// End drag operation FIRST - before the rename triggers a re-render // Note: dragCompleted() now called by FolderItem.handleMouseUp before this action
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo( UndoQueue.instance.pushAndDo(
new UndoActionGroup({ new UndoActionGroup({
@@ -612,14 +606,12 @@ export function useComponentActions() {
// Check for naming conflicts // Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) { if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists`); alert(`Component "${newName}" already exists`);
PopupLayer.instance.dragCompleted();
return; return;
} }
const oldName = component.name; const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render // Note: dragCompleted() now called by ComponentItem.handleMouseUp before this action
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo( UndoQueue.instance.pushAndDo(
new UndoActionGroup({ new UndoActionGroup({
@@ -646,7 +638,6 @@ export function useComponentActions() {
if (!componentsToMove || componentsToMove.length === 0) { if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move'); console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
return; return;
} }
@@ -670,12 +661,10 @@ export function useComponentActions() {
if (hasConflict) { if (hasConflict) {
alert(`Some components would conflict with existing names`); alert(`Some components would conflict with existing names`);
PopupLayer.instance.dragCompleted();
return; return;
} }
// End drag operation FIRST - before the rename triggers a re-render // Note: dragCompleted() now called by ComponentItem.handleMouseUp before this action
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo( UndoQueue.instance.pushAndDo(
new UndoActionGroup({ new UndoActionGroup({

View File

@@ -3,11 +3,26 @@ import StringListTemplate from '../../../../../templates/propertyeditor/stringli
import PopupLayer from '../../../../popuplayer'; import PopupLayer from '../../../../popuplayer';
import { ToastLayer } from '../../../../ToastLayer/ToastLayer'; import { ToastLayer } from '../../../../ToastLayer/ToastLayer';
// Helper to normalize list input - handles both string and array formats
function normalizeList(value) {
if (value === undefined || value === null || value === '') {
return [];
}
if (Array.isArray(value)) {
// Already an array (legacy proplist data) - extract labels if objects
return value.map((item) => (typeof item === 'object' && item.label ? item.label : String(item)));
}
if (typeof value === 'string') {
return value.split(',').filter(Boolean);
}
return [];
}
var StringList = function (args) { var StringList = function (args) {
View.call(this); View.call(this);
this.parent = args.parent; this.parent = args.parent;
this.list = args.list !== undefined && args.list !== '' ? args.list.split(',') : []; this.list = normalizeList(args.list);
this.type = args.type; this.type = args.type;
this.plug = args.plug; this.plug = args.plug;
this.title = args.title; this.title = args.title;
@@ -32,7 +47,7 @@ StringList.prototype.listUpdated = function () {
if (this.onUpdate) { if (this.onUpdate) {
var updated = this.onUpdate(this.list.join(',')); var updated = this.onUpdate(this.list.join(','));
if (updated) { if (updated) {
this.list = updated.value ? updated.value.split(',') : []; this.list = normalizeList(updated.value);
this.isDefault = updated.isDefault; this.isDefault = updated.isDefault;
} }
} }

View File

@@ -23,7 +23,7 @@ function registerNodes(noodlRuntime) {
// Data // Data
require('./src/nodes/std-library/data/restnode'), require('./src/nodes/std-library/data/restnode'),
require('./src/nodes/std-library/data/httpnode'), // require('./src/nodes/std-library/data/httpnode'), // moved to viewer for debugging
// Custom code // Custom code
require('./src/nodes/std-library/expression'), require('./src/nodes/std-library/expression'),

View File

@@ -1,19 +1,22 @@
"use strict"; 'use strict';
function createSetter(args) { function createSetter(args) {
console.log('[EdgeTriggeredInput] 🔧 createSetter called for signal input');
var currentValue = false; var currentValue = false;
return function(value) { return function (value) {
value = value ? true : false; console.log('[EdgeTriggeredInput] ⚡ setter called with value:', value, 'currentValue:', currentValue);
//value changed from false to true value = value ? true : false;
if(value && currentValue === false) { //value changed from false to true
args.valueChangedToTrue.call(this); if (value && currentValue === false) {
} console.log('[EdgeTriggeredInput] ✅ Triggering valueChangedToTrue!');
currentValue = value; args.valueChangedToTrue.call(this);
}; }
currentValue = value;
};
} }
module.exports = { module.exports = {
createSetter: createSetter createSetter: createSetter
}; };

View File

@@ -84,12 +84,22 @@ Node.prototype.registerInputIfNeeded = function () {
}; };
Node.prototype.setInputValue = function (name, value) { Node.prototype.setInputValue = function (name, value) {
// DEBUG: Track input value setting for HTTP node
if (this.name === 'net.noodl.HTTP') {
console.log('[Node.setInputValue] 🎯 HTTP node setting input:', name, 'value:', value);
}
const input = this.getInput(name); const input = this.getInput(name);
if (!input) { if (!input) {
console.log("node doesn't have input", name); console.log("node doesn't have input", name, 'for node:', this.name);
return; return;
} }
// DEBUG: Track if setter exists
if (this.name === 'net.noodl.HTTP' && name === 'fetch') {
console.log('[Node.setInputValue] 🎯 HTTP fetch input found, calling setter');
}
//inputs with units always expect objects in the shape of {value, unit, ...} //inputs with units always expect objects in the shape of {value, unit, ...}
//these inputs might sometimes get raw numbers without units, and in those cases //these inputs might sometimes get raw numbers without units, and in those cases
//Noodl should just update the value and not the other parameters //Noodl should just update the value and not the other parameters

View File

@@ -200,6 +200,7 @@ function defineNode(opts) {
Object.keys(opts.inputs).forEach(function (name) { Object.keys(opts.inputs).forEach(function (name) {
var input = opts.inputs[name]; var input = opts.inputs[name];
if (input.valueChangedToTrue) { if (input.valueChangedToTrue) {
console.log('[NodeDefinition] 📌 Registering signal input:', name, 'for node:', opts.name);
node._inputs[name] = { node._inputs[name] = {
set: EdgeTriggeredInput.createSetter({ set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: input.valueChangedToTrue valueChangedToTrue: input.valueChangedToTrue

View File

@@ -19,6 +19,9 @@
// Note: This file uses CommonJS module format to match the noodl-runtime pattern // Note: This file uses CommonJS module format to match the noodl-runtime pattern
// DEBUG: Confirm module is loaded
console.log('[HTTP Node Module] 📦 httpnode.js MODULE LOADED');
/** /**
* Extract value from object using JSONPath-like syntax * Extract value from object using JSONPath-like syntax
* Supports: $.data.users, $.items[0].name, $.meta.pagination.total * Supports: $.data.users, $.items[0].name, $.meta.pagination.total
@@ -88,12 +91,13 @@ var HttpNode = {
searchTags: ['http', 'request', 'fetch', 'api', 'rest', 'curl'], searchTags: ['http', 'request', 'fetch', 'api', 'rest', 'curl'],
initialize: function () { initialize: function () {
console.log('[HTTP Node] 🚀 INITIALIZE called - node instance created');
this._internal.inputValues = {}; this._internal.inputValues = {};
this._internal.outputValues = {}; this._internal.outputValues = {};
this._internal.headers = []; this._internal.headers = '';
this._internal.queryParams = []; this._internal.queryParams = '';
this._internal.bodyFields = []; this._internal.bodyFields = '';
this._internal.responseMapping = []; this._internal.responseMapping = '';
this._internal.inspectData = null; this._internal.inspectData = null;
}, },
@@ -105,6 +109,7 @@ var HttpNode = {
}, },
inputs: { inputs: {
// Static inputs - these don't need to trigger port regeneration
url: { url: {
type: 'string', type: 'string',
displayName: 'URL', displayName: 'URL',
@@ -114,40 +119,12 @@ var HttpNode = {
this._internal.url = value; this._internal.url = value;
} }
}, },
method: {
type: {
name: 'enum',
enums: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' }
]
},
displayName: 'Method',
group: 'Request',
default: 'GET',
set: function (value) {
this._internal.method = value;
}
},
timeout: {
type: 'number',
displayName: 'Timeout (ms)',
group: 'Request',
default: 30000,
set: function (value) {
this._internal.timeout = value;
}
},
fetch: { fetch: {
type: 'signal', type: 'signal',
displayName: 'Fetch', displayName: 'Fetch',
group: 'Actions', group: 'Actions',
valueChangedToTrue: function () { valueChangedToTrue: function () {
console.log('[HTTP Node] ⚡ FETCH SIGNAL RECEIVED - valueChangedToTrue triggered!');
this.scheduleFetch(); this.scheduleFetch();
} }
}, },
@@ -159,6 +136,7 @@ var HttpNode = {
this.cancelFetch(); this.cancelFetch();
} }
} }
// Note: method, timeout, and config ports are now dynamic (in updatePorts)
}, },
outputs: { outputs: {
@@ -212,7 +190,9 @@ var HttpNode = {
}, },
prototypeExtensions: { prototypeExtensions: {
setInputValue: function (name, value) { // Store values for dynamic inputs only - static inputs (including signals)
// use the base Node.prototype.setInputValue which calls input.set()
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value; this._internal.inputValues[name] = value;
}, },
@@ -233,25 +213,45 @@ var HttpNode = {
registerInputIfNeeded: function (name) { registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return; if (this.hasInput(name)) return;
// Dynamic inputs for path params, headers, query params, body fields, auth // Configuration inputs - these set internal state
const inputSetters = { const configSetters = {
path: this.setInputValue.bind(this), method: this.setMethod.bind(this),
header: this.setInputValue.bind(this), timeout: this.setTimeout.bind(this),
query: this.setInputValue.bind(this), headers: this.setHeaders.bind(this),
body: this.setInputValue.bind(this), queryParams: this.setQueryParams.bind(this),
auth: this.setInputValue.bind(this) bodyType: this.setBodyType.bind(this),
bodyFields: this.setBodyFields.bind(this),
authType: this.setAuthType.bind(this),
responseMapping: this.setResponseMapping.bind(this)
}; };
for (const [prefix, setter] of Object.entries(inputSetters)) { if (configSetters[name]) {
if (name.startsWith(prefix + '-')) { return this.registerInput(name, {
return this.registerInput(name, { set: setter.bind(this, name) }); set: configSetters[name]
});
}
// Dynamic inputs for path params, headers, query params, body fields, auth, mapping paths
// These use _storeInputValue to just store values (no signal trigger needed)
const dynamicPrefixes = ['path-', 'header-', 'query-', 'body-', 'auth-', 'mapping-path-'];
for (const prefix of dynamicPrefixes) {
if (name.startsWith(prefix)) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
} }
} }
}, },
scheduleFetch: function () { scheduleFetch: function () {
if (this._internal.hasScheduledFetch) return; console.log('[HTTP Node] scheduleFetch called');
if (this._internal.hasScheduledFetch) {
console.log('[HTTP Node] Already scheduled, skipping');
return;
}
this._internal.hasScheduledFetch = true; this._internal.hasScheduledFetch = true;
console.log('[HTTP Node] Scheduling doFetch after inputs update');
this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this)); this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this));
}, },
@@ -278,12 +278,16 @@ var HttpNode = {
// Add query parameters // Add query parameters
const queryParams = {}; const queryParams = {};
// From visual config // From visual config (stringlist format)
if (this._internal.queryParams) { if (this._internal.queryParams) {
for (const qp of this._internal.queryParams) { const queryList = this._internal.queryParams
const value = this._internal.inputValues['query-' + qp.key]; .split(',')
.map((q) => q.trim())
.filter(Boolean);
for (const qp of queryList) {
const value = this._internal.inputValues['query-' + qp];
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== null && value !== '') {
queryParams[qp.key] = value; queryParams[qp] = value;
} }
} }
} }
@@ -312,12 +316,16 @@ var HttpNode = {
buildHeaders: function () { buildHeaders: function () {
const headers = {}; const headers = {};
// From visual config // From visual config (stringlist format)
if (this._internal.headers) { if (this._internal.headers) {
for (const h of this._internal.headers) { const headerList = this._internal.headers
const value = this._internal.inputValues['header-' + h.key]; .split(',')
.map((h) => h.trim())
.filter(Boolean);
for (const h of headerList) {
const value = this._internal.inputValues['header-' + h];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
headers[h.key] = String(value); headers[h] = String(value);
} }
} }
} }
@@ -341,32 +349,38 @@ var HttpNode = {
} }
const bodyType = this._internal.bodyType || 'json'; const bodyType = this._internal.bodyType || 'json';
const bodyFields = this._internal.bodyFields || []; const bodyFieldsStr = this._internal.bodyFields || '';
// Parse stringlist format
const bodyFields = bodyFieldsStr
.split(',')
.map((f) => f.trim())
.filter(Boolean);
if (bodyType === 'json') { if (bodyType === 'json') {
const body = {}; const body = {};
for (const field of bodyFields) { for (const field of bodyFields) {
const value = this._internal.inputValues['body-' + field.key]; const value = this._internal.inputValues['body-' + field];
if (value !== undefined) { if (value !== undefined) {
body[field.key] = value; body[field] = value;
} }
} }
return Object.keys(body).length > 0 ? JSON.stringify(body) : undefined; return Object.keys(body).length > 0 ? JSON.stringify(body) : undefined;
} else if (bodyType === 'form') { } else if (bodyType === 'form') {
const formData = new FormData(); const formData = new FormData();
for (const field of bodyFields) { for (const field of bodyFields) {
const value = this._internal.inputValues['body-' + field.key]; const value = this._internal.inputValues['body-' + field];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
formData.append(field.key, value); formData.append(field, value);
} }
} }
return formData; return formData;
} else if (bodyType === 'urlencoded') { } else if (bodyType === 'urlencoded') {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const field of bodyFields) { for (const field of bodyFields) {
const value = this._internal.inputValues['body-' + field.key]; const value = this._internal.inputValues['body-' + field];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
params.append(field.key, String(value)); params.append(field, String(value));
} }
} }
return params.toString(); return params.toString();
@@ -390,10 +404,20 @@ var HttpNode = {
this._internal.responseHeaders = responseHeaders; this._internal.responseHeaders = responseHeaders;
// Process response mappings // Process response mappings
const mappings = this._internal.responseMapping || []; // Output names are in responseMapping (comma-separated)
for (const mapping of mappings) { // Path for each output is in inputValues['mapping-path-{name}']
const outputName = 'out-' + mapping.name; const mappingStr = this._internal.responseMapping || '';
const value = extractByPath(responseBody, mapping.path); const outputNames = mappingStr
.split(',')
.map((m) => m.trim())
.filter(Boolean);
for (const name of outputNames) {
// Get the path from the corresponding input port
const path = this._internal.inputValues['mapping-path-' + name] || '$';
const outputName = 'out-' + name;
const value = extractByPath(responseBody, path);
this.registerOutputIfNeeded(outputName); this.registerOutputIfNeeded(outputName);
this._internal.outputValues[outputName] = value; this._internal.outputValues[outputName] = value;
@@ -415,6 +439,7 @@ var HttpNode = {
}, },
doFetch: function () { doFetch: function () {
console.log('[HTTP Node] doFetch executing');
this._internal.hasScheduledFetch = false; this._internal.hasScheduledFetch = false;
const url = this.buildUrl(); const url = this.buildUrl();
@@ -423,11 +448,20 @@ var HttpNode = {
const body = this.buildBody(); const body = this.buildBody();
const timeout = this._internal.timeout || 30000; const timeout = this._internal.timeout || 30000;
console.log('[HTTP Node] Request details:', {
url,
method,
headers,
body: body ? '(has body)' : '(no body)',
timeout
});
// Store for inspect // Store for inspect
this._internal.lastRequestUrl = url; this._internal.lastRequestUrl = url;
// Validate URL // Validate URL
if (!url) { if (!url) {
console.log('[HTTP Node] No URL provided, sending failure');
this._internal.error = 'URL is required'; this._internal.error = 'URL is required';
this.flagOutputDirty('error'); this.flagOutputDirty('error');
this.sendSignalOnOutput('failure'); this.sendSignalOnOutput('failure');
@@ -503,11 +537,11 @@ var HttpNode = {
// Configuration setters called from setup function // Configuration setters called from setup function
setHeaders: function (value) { setHeaders: function (value) {
this._internal.headers = value || []; this._internal.headers = value || '';
}, },
setQueryParams: function (value) { setQueryParams: function (value) {
this._internal.queryParams = value || []; this._internal.queryParams = value || '';
}, },
setBodyType: function (value) { setBodyType: function (value) {
@@ -515,15 +549,23 @@ var HttpNode = {
}, },
setBodyFields: function (value) { setBodyFields: function (value) {
this._internal.bodyFields = value || []; this._internal.bodyFields = value || '';
}, },
setResponseMapping: function (value) { setResponseMapping: function (value) {
this._internal.responseMapping = value || []; this._internal.responseMapping = value || '';
}, },
setAuthType: function (value) { setAuthType: function (value) {
this._internal.authType = value; this._internal.authType = value;
},
setMethod: function (value) {
this._internal.method = value || 'GET';
},
setTimeout: function (value) {
this._internal.timeout = value || 30000;
} }
} }
}; };
@@ -534,36 +576,6 @@ var HttpNode = {
function updatePorts(nodeId, parameters, editorConnection) { function updatePorts(nodeId, parameters, editorConnection) {
const ports = []; const ports = [];
// URL input (already defined in static inputs, but we want it first)
ports.push({
name: 'url',
displayName: 'URL',
type: 'string',
plug: 'input',
group: 'Request'
});
// Method input
ports.push({
name: 'method',
displayName: 'Method',
type: {
name: 'enum',
enums: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' }
]
},
default: 'GET',
plug: 'input',
group: 'Request'
});
// Parse URL for path parameters: /users/{userId} → userId port // Parse URL for path parameters: /users/{userId} → userId port
if (parameters.url) { if (parameters.url) {
const pathParams = parameters.url.match(/\{([A-Za-z0-9_]+)\}/g) || []; const pathParams = parameters.url.match(/\{([A-Za-z0-9_]+)\}/g) || [];
@@ -580,7 +592,7 @@ function updatePorts(nodeId, parameters, editorConnection) {
} }
} }
// Headers configuration // Headers configuration - comma-separated list
ports.push({ ports.push({
name: 'headers', name: 'headers',
displayName: 'Headers', displayName: 'Headers',
@@ -590,21 +602,23 @@ function updatePorts(nodeId, parameters, editorConnection) {
}); });
// Generate input ports for each header // Generate input ports for each header
if (parameters.headers && Array.isArray(parameters.headers)) { if (parameters.headers) {
for (const header of parameters.headers) { const headerList = parameters.headers
if (header.key) { .split(',')
ports.push({ .map((h) => h.trim())
name: 'header-' + header.key, .filter(Boolean);
displayName: header.key, for (const header of headerList) {
type: 'string', ports.push({
plug: 'input', name: 'header-' + header,
group: 'Headers' displayName: header,
}); type: 'string',
} plug: 'input',
group: 'Headers'
});
} }
} }
// Query parameters configuration // Query parameters configuration - comma-separated list
ports.push({ ports.push({
name: 'queryParams', name: 'queryParams',
displayName: 'Query Parameters', displayName: 'Query Parameters',
@@ -614,20 +628,44 @@ function updatePorts(nodeId, parameters, editorConnection) {
}); });
// Generate input ports for each query param // Generate input ports for each query param
if (parameters.queryParams && Array.isArray(parameters.queryParams)) { if (parameters.queryParams) {
for (const param of parameters.queryParams) { const queryList = parameters.queryParams
if (param.key) { .split(',')
ports.push({ .map((q) => q.trim())
name: 'query-' + param.key, .filter(Boolean);
displayName: param.key, for (const param of queryList) {
type: '*', ports.push({
plug: 'input', name: 'query-' + param,
group: 'Query Parameters' displayName: param,
}); type: 'string',
} plug: 'input',
group: 'Query Parameters'
});
} }
} }
// Method selector as dynamic port (so we can track parameter changes properly)
ports.push({
name: 'method',
displayName: 'Method',
type: {
name: 'enum',
enums: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' }
],
allowEditOnly: true
},
default: 'GET',
plug: 'input',
group: 'Request'
});
// Body type selector (only shown for POST/PUT/PATCH) // Body type selector (only shown for POST/PUT/PATCH)
const method = parameters.method || 'GET'; const method = parameters.method || 'GET';
if (['POST', 'PUT', 'PATCH'].includes(method)) { if (['POST', 'PUT', 'PATCH'].includes(method)) {
@@ -649,7 +687,7 @@ function updatePorts(nodeId, parameters, editorConnection) {
group: 'Body' group: 'Body'
}); });
// Body fields configuration // Body fields configuration - comma-separated list
const bodyType = parameters.bodyType || 'json'; const bodyType = parameters.bodyType || 'json';
if (bodyType === 'json' || bodyType === 'form' || bodyType === 'urlencoded') { if (bodyType === 'json' || bodyType === 'form' || bodyType === 'urlencoded') {
ports.push({ ports.push({
@@ -660,25 +698,56 @@ function updatePorts(nodeId, parameters, editorConnection) {
group: 'Body' group: 'Body'
}); });
// Generate input ports for each body field // Type options for body fields
if (parameters.bodyFields && Array.isArray(parameters.bodyFields)) { const _types = [
for (const field of parameters.bodyFields) { { label: 'String', value: 'string' },
if (field.key) { { label: 'Number', value: 'number' },
ports.push({ { label: 'Boolean', value: 'boolean' },
name: 'body-' + field.key, { label: 'Array', value: 'array' },
displayName: field.key, { label: 'Object', value: 'object' },
type: '*', { label: 'Any', value: '*' }
plug: 'input', ];
group: 'Body'
}); // Generate type selector and value input ports for each body field
} if (parameters.bodyFields) {
const fieldList = parameters.bodyFields
.split(',')
.map((f) => f.trim())
.filter(Boolean);
for (const field of fieldList) {
// Get the selected type for this field (default to string)
const fieldType = parameters['body-type-' + field] || 'string';
// Type selector for this field
ports.push({
name: 'body-type-' + field,
displayName: field + ' Type',
type: {
name: 'enum',
enums: _types,
allowEditOnly: true
},
default: 'string',
plug: 'input',
group: 'Body'
});
// Value input for this field (type matches selected type)
ports.push({
name: 'body-' + field,
displayName: field,
type: fieldType,
plug: 'input',
group: 'Body'
});
} }
} }
} else if (bodyType === 'raw') { } else if (bodyType === 'raw') {
// Raw body - use code editor with JSON syntax
ports.push({ ports.push({
name: 'body-raw', name: 'body-raw',
displayName: 'Body', displayName: 'Body',
type: 'string', type: { name: 'string', allowEditOnly: true, codeeditor: 'json' },
plug: 'input', plug: 'input',
group: 'Body' group: 'Body'
}); });
@@ -760,41 +829,28 @@ function updatePorts(nodeId, parameters, editorConnection) {
}); });
} }
// Response mapping // Timeout setting
ports.push({
name: 'responseMapping',
displayName: 'Response Mapping',
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Response Mapping'
});
// Generate output ports for each response mapping
if (parameters.responseMapping && Array.isArray(parameters.responseMapping)) {
for (const mapping of parameters.responseMapping) {
if (mapping.name) {
ports.push({
name: 'out-' + mapping.name,
displayName: mapping.name,
type: '*',
plug: 'output',
group: 'Response'
});
}
}
}
// Timeout
ports.push({ ports.push({
name: 'timeout', name: 'timeout',
displayName: 'Timeout (ms)', displayName: 'Timeout (ms)',
type: 'number', type: 'number',
default: 30000, default: 30000,
plug: 'input', plug: 'input',
group: 'Settings' group: 'Request'
}); });
// Actions // Response mapping - add output names, then specify JSONPath for each
// User adds output names like: userId, userName, totalCount
// For each name, we generate a "Path" input and an output port
ports.push({
name: 'responseMapping',
displayName: 'Output Fields',
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Response Mapping'
});
// Signal inputs - MUST be in dynamic ports for editor to show them
ports.push({ ports.push({
name: 'fetch', name: 'fetch',
displayName: 'Fetch', displayName: 'Fetch',
@@ -811,6 +867,44 @@ function updatePorts(nodeId, parameters, editorConnection) {
group: 'Actions' group: 'Actions'
}); });
// URL input - also needs to be in dynamic ports
ports.push({
name: 'url',
displayName: 'URL',
type: 'string',
plug: 'input',
group: 'Request'
});
// Generate path input ports and output ports for each response mapping
if (parameters.responseMapping && typeof parameters.responseMapping === 'string') {
const outputNames = parameters.responseMapping
.split(',')
.map((m) => m.trim())
.filter(Boolean);
for (const name of outputNames) {
// Path input port for specifying JSONPath (e.g., $.data.id)
ports.push({
name: 'mapping-path-' + name,
displayName: name + ' Path',
type: 'string',
default: '$',
plug: 'input',
group: 'Response Mapping'
});
// Output port for the extracted value
ports.push({
name: 'out-' + name,
displayName: name,
type: '*',
plug: 'output',
group: 'Response'
});
}
}
// Standard outputs // Standard outputs
ports.push({ ports.push({
name: 'response', name: 'response',
@@ -891,7 +985,8 @@ module.exports = {
event.name === 'bodyType' || event.name === 'bodyType' ||
event.name === 'bodyFields' || event.name === 'bodyFields' ||
event.name === 'authType' || event.name === 'authType' ||
event.name === 'responseMapping' event.name === 'responseMapping' ||
event.name.startsWith('body-type-') // Body field type changes
) { ) {
updatePorts(node.id, node.parameters, context.editorConnection); updatePorts(node.id, node.parameters, context.editorConnection);
} }

View File

@@ -60,6 +60,9 @@ export default function registerNodes(noodlRuntime) {
require('./nodes/std-library/colorblend'), require('./nodes/std-library/colorblend'),
require('./nodes/std-library/animate-to-value'), require('./nodes/std-library/animate-to-value'),
// HTTP node - temporarily here for debugging (normally in noodl-runtime)
require('@noodl/runtime/src/nodes/std-library/data/httpnode'),
//require('./nodes/std-library/variables/number'), // moved to runtime //require('./nodes/std-library/variables/number'), // moved to runtime
//require('./nodes/std-library/variables/string'), //require('./nodes/std-library/variables/string'),
//require('./nodes/std-library/variables/boolean'), //require('./nodes/std-library/variables/boolean'),