@revenexx/integrations-node-sdk 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,38 +42,180 @@ ESM/CJS output plus `.d.ts` types.
|
|
|
42
42
|
|
|
43
43
|
### Authoring a node
|
|
44
44
|
|
|
45
|
-
Implement `INode`:
|
|
46
|
-
|
|
45
|
+
Implement `INode`: an instance `description` property (the metadata the registry
|
|
46
|
+
publishes) plus an `execute` method. A few conventions the engine relies on:
|
|
47
|
+
|
|
48
|
+
- **`slug` is namespaced** — `<namespace>:<slug>`, e.g. `revenexx:text-replace`.
|
|
49
|
+
It is the stable identity of the node across versions; not a dotted path.
|
|
50
|
+
- **Every output port carries a `name`.** `execute` returns an `outputs` map
|
|
51
|
+
keyed by that port `name`, and sets `branch` to the port that fired. The key,
|
|
52
|
+
the `branch` and the declared `name` must match — that triple is how the
|
|
53
|
+
engine routes the result to the next node.
|
|
54
|
+
- **Config and inputs arrive in the same map.** The engine resolves each
|
|
55
|
+
declared `config` field (keyed by its `key`) and the input ports (`'in'`, plus
|
|
56
|
+
any named fan-in ports) into the single `inputs: Record<string, unknown>`
|
|
57
|
+
handed to `execute`. So `inputs['in']` is the upstream payload and
|
|
58
|
+
`inputs['<config-key>']` is the resolved config value.
|
|
59
|
+
- **`ctx.signal` is always provided** — propagate it to every I/O call.
|
|
60
|
+
|
|
61
|
+
#### Transform node — single input, single output
|
|
62
|
+
|
|
63
|
+
A `transform` node with one `'in'` port and one named `out` port. Mirrors
|
|
64
|
+
`TextReplaceNode` from `integrations-nodes-core`:
|
|
47
65
|
|
|
48
66
|
```ts
|
|
49
|
-
import type { INode, INodeContext } from '@revenexx/integrations-node-sdk';
|
|
67
|
+
import type { INode, INodeContext, INodeDescription, INodeResult } from '@revenexx/integrations-node-sdk';
|
|
50
68
|
import { NodeError } from '@revenexx/integrations-node-sdk';
|
|
51
69
|
|
|
52
70
|
export class UppercaseNode implements INode {
|
|
53
|
-
description
|
|
54
|
-
slug: 'text
|
|
71
|
+
description = {
|
|
72
|
+
slug: 'revenexx:text-uppercase',
|
|
55
73
|
version: '1.0.0',
|
|
56
74
|
category: 'transform',
|
|
57
|
-
name: 'Uppercase',
|
|
75
|
+
name: { en: 'Uppercase', de: 'Großschreiben' },
|
|
76
|
+
description: { en: 'Upper-cases a string read from the input.' },
|
|
77
|
+
icon: 'mdi:format-letter-case-upper',
|
|
58
78
|
inputs: { in: { dataType: 'string', required: true } },
|
|
59
|
-
outputs: [{ kind: 'default', dataType: 'string' }],
|
|
60
|
-
};
|
|
79
|
+
outputs: [{ name: 'out', kind: 'default', dataType: 'string', label: { en: 'Result', de: 'Ergebnis' } }],
|
|
80
|
+
} satisfies INodeDescription;
|
|
61
81
|
|
|
62
|
-
async execute(ctx: INodeContext, inputs: Record<string, unknown>) {
|
|
82
|
+
async execute(ctx: INodeContext, inputs: Record<string, unknown>): Promise<INodeResult> {
|
|
63
83
|
ctx.signal.throwIfAborted();
|
|
64
84
|
const value = inputs['in'];
|
|
65
85
|
if (typeof value !== 'string') {
|
|
66
|
-
|
|
86
|
+
// NodeError(code, message, meta?) — a stable code plus a human message.
|
|
87
|
+
throw new NodeError('INVALID_INPUT', 'expected a string on the `in` port');
|
|
67
88
|
}
|
|
68
|
-
return { outputs: { out: value.toUpperCase() } };
|
|
89
|
+
return { outputs: { out: value.toUpperCase() }, branch: 'out' };
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
```
|
|
72
93
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
`
|
|
94
|
+
#### Control node — branch outputs
|
|
95
|
+
|
|
96
|
+
A `control` node declares `kind: 'branch'` ports and routes by returning the
|
|
97
|
+
matching `branch`. Condensed from `ConditionIfNode`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
export class ConditionIfNode implements INode {
|
|
101
|
+
description = {
|
|
102
|
+
slug: 'revenexx:condition-if',
|
|
103
|
+
version: '1.0.0',
|
|
104
|
+
category: 'control',
|
|
105
|
+
name: { en: 'If condition', de: 'Wenn-Bedingung' },
|
|
106
|
+
icon: 'mdi:source-branch',
|
|
107
|
+
inputs: { in: { dataType: 'any', required: true } },
|
|
108
|
+
outputs: [
|
|
109
|
+
{ name: 'true', kind: 'branch', dataType: 'any', label: { en: 'True', de: 'Wahr' } },
|
|
110
|
+
{ name: 'false', kind: 'branch', dataType: 'any', label: { en: 'False', de: 'Falsch' } },
|
|
111
|
+
],
|
|
112
|
+
config: [
|
|
113
|
+
{ key: 'field', label: { en: 'Field (dot-path)' }, type: 'string', required: true },
|
|
114
|
+
{ key: 'value', label: { en: 'Value' }, type: 'string', expressionAllowed: true },
|
|
115
|
+
],
|
|
116
|
+
} satisfies INodeDescription;
|
|
117
|
+
|
|
118
|
+
async execute(ctx: INodeContext, inputs: Record<string, unknown>): Promise<INodeResult> {
|
|
119
|
+
const inputData = inputs['in'];
|
|
120
|
+
const field = inputs['field'];
|
|
121
|
+
// `field` is a dot-path selector — resolve it against the input payload,
|
|
122
|
+
// then compare the resolved value to the configured `value`.
|
|
123
|
+
const actual =
|
|
124
|
+
typeof field === 'string'
|
|
125
|
+
? field
|
|
126
|
+
.split('.')
|
|
127
|
+
.reduce<unknown>(
|
|
128
|
+
(acc, key) => (acc == null ? undefined : (acc as Record<string, unknown>)[key]),
|
|
129
|
+
inputData,
|
|
130
|
+
)
|
|
131
|
+
: undefined;
|
|
132
|
+
const branch = actual === inputs['value'] ? 'true' : 'false';
|
|
133
|
+
// The fired port's name is both the outputs key and the branch.
|
|
134
|
+
return { outputs: { [branch]: inputData }, branch };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
#### Action node — error output & credentials
|
|
140
|
+
|
|
141
|
+
An `action` node declares a `kind: 'error'` port for expected, routable
|
|
142
|
+
failures and reads a credential instance via a `credentials-ref` config field.
|
|
143
|
+
Condensed from `HttpRequestNode`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
export class HttpRequestNode implements INode {
|
|
147
|
+
description = {
|
|
148
|
+
slug: 'revenexx:http-request',
|
|
149
|
+
version: '1.0.0',
|
|
150
|
+
category: 'action',
|
|
151
|
+
name: { en: 'HTTP Request', de: 'HTTP-Anfrage' },
|
|
152
|
+
icon: 'mdi:web',
|
|
153
|
+
inputs: { in: { dataType: 'object' } },
|
|
154
|
+
outputs: [
|
|
155
|
+
{
|
|
156
|
+
name: 'response',
|
|
157
|
+
kind: 'default',
|
|
158
|
+
dataType: 'object',
|
|
159
|
+
label: { en: 'Response', de: 'Antwort' },
|
|
160
|
+
fields: { status: { dataType: 'number' }, body: { dataType: 'any' } },
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'error',
|
|
164
|
+
kind: 'error',
|
|
165
|
+
dataType: 'object',
|
|
166
|
+
label: { en: 'Error', de: 'Fehler' },
|
|
167
|
+
fields: { code: { dataType: 'string' }, message: { dataType: 'string' } },
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
config: [
|
|
171
|
+
{ key: 'url', label: 'URL', type: 'string', required: true, expressionAllowed: true },
|
|
172
|
+
{
|
|
173
|
+
key: 'credentials',
|
|
174
|
+
label: { en: 'Credentials', de: 'Anmeldedaten' },
|
|
175
|
+
type: 'credentials-ref',
|
|
176
|
+
credentialType: 'revenexx:http-bearer',
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
} satisfies INodeDescription;
|
|
180
|
+
|
|
181
|
+
async execute(ctx: INodeContext, inputs: Record<string, unknown>): Promise<INodeResult> {
|
|
182
|
+
const url = inputs['url'];
|
|
183
|
+
if (typeof url !== 'string' || !url) {
|
|
184
|
+
throw new NodeError('MISSING_URL', 'URL config field is required');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
188
|
+
const credentialsRef = inputs['credentials'];
|
|
189
|
+
if (typeof credentialsRef === 'string') {
|
|
190
|
+
const creds = await ctx.credentials.get(credentialsRef);
|
|
191
|
+
if (typeof creds['token'] === 'string') headers['Authorization'] = `Bearer ${creds['token']}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(url, { headers, signal: ctx.signal }); // always propagate ctx.signal
|
|
196
|
+
const body = await res.json();
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
return {
|
|
199
|
+
outputs: { error: { code: 'HTTP_ERROR', message: `${url} → ${res.status}` } },
|
|
200
|
+
branch: 'error',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return { outputs: { response: { status: res.status, body } }, branch: 'response' };
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err instanceof NodeError) throw err;
|
|
206
|
+
// Don't swallow cancellations/timeouts — let aborts propagate so the
|
|
207
|
+
// workflow can cancel promptly instead of routing to the error port.
|
|
208
|
+
if (ctx.signal.aborted || (err instanceof Error && err.name === 'AbortError')) throw err;
|
|
209
|
+
return { outputs: { error: { code: 'REQUEST_FAILED', message: String(err) } }, branch: 'error' };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Error contract:** `throw NodeError(code, message, meta?)` for unexpected,
|
|
216
|
+
system-level failures; `return { outputs, branch: '<error-port>' }` for
|
|
217
|
+
expected, routable errors (a declared `kind: 'error'` port). Never mix both for
|
|
218
|
+
the same condition.
|
|
77
219
|
|
|
78
220
|
### Building a manifest
|
|
79
221
|
|