Skip to content

Contributing a Workflow Node

LenserFight's Workflow engine is extensible through the Node Runner system. Each node type is a self-contained class implementing the INodeRunner interface. This guide walks you through adding a new node from scratch.

Architecture Overview

The runner system follows GRASP principles:

  • Polymorphism — all runners share a common INodeRunner interface
  • Information Expert — each runner owns its execution logic
  • Pure FabricationNodeRunnerRegistry decouples dispatch from implementation
  • Low Coupling — runners depend only on NodeRunnerContext, not the full service
libs/infra/execution/src/lib/
├── runners/
│   ├── node-runner.interface.ts   # INodeRunner + context types
│   ├── node-runner.registry.ts    # Registration + lookup
│   ├── index.ts                   # Barrel exports
│   ├── set-variables.runner.ts    # Example: simplest runner
│   ├── json-transform.runner.ts   # Example: data transform
│   ├── switch.runner.ts           # Example: multi-way branch
│   ├── code-node.runner.ts        # Example: sandboxed JS execution
│   └── ...
├── execution.types.ts             # WorkflowNodeType union
└── ...

Step 1: Add the Node Type

Add your node type to the WorkflowNodeType union in execution.types.ts:

typescript
export type WorkflowNodeType =
  | 'text'
  | 'image'
  // ... existing types ...
  | 'your_node_type'  // Add here

Add the corresponding canvas type in libs/features/workflows/src/lib/components/WorkflowCanvasNode.tsx:

typescript
node_type?:
  | 'lens'
  | 'image_generate'
  // ... existing types ...
  | 'your_node_type'  // Add here

Step 2: Implement the Runner

Create libs/infra/execution/src/lib/runners/your-node.runner.ts:

typescript
import type { WorkflowNodeType } from '../execution.types'
import type { INodeRunner, NodeRunnerContext, NodeRunnerResult } from './node-runner.interface'

export class YourNodeRunner implements INodeRunner {
  readonly nodeType: WorkflowNodeType = 'your_node_type'

  async execute(ctx: NodeRunnerContext): Promise<NodeRunnerResult> {
    // Access upstream outputs
    const upstream = ctx.upstreamOutputs.get('source-node-id')

    // Access per-node config from the canvas
    const myOption = ctx.nodeConfig['myOption'] as string

    // Access workflow-scoped variables
    const globalVar = ctx.resolvedParams['someVar']

    // Return result
    return {
      output: {
        mediaType: 'text',
        text: 'Result text',
        data: { /* structured output */ },
        durationMs: 0,
      },
      // Optional: mutate workflow-scoped variables for downstream nodes
      variableMutations: { newVar: 'value' },
    }
  }
}

NodeRunnerContext Fields

FieldTypeDescription
nodeIdstringUnique node ID within the run
upstreamOutputsReadonlyMap<string, ExecutionResult>Outputs from completed upstream nodes
resolvedParamsRecord<string, unknown>Workflow-scoped variables (root inputs + accumulated mutations)
nodeConfigRecord<string, unknown>Per-node config from the canvas UI
signalAbortSignal?Cooperative cancellation signal

NodeRunnerResult Fields

FieldTypeDescription
outputExecutionResultStandard result — wrapped in NodeOutputEnvelope by engine
variableMutationsRecord<string, unknown>?Optional: merged into resolvedParams for all downstream nodes

Step 3: Register and Export

Add to runners/index.ts:

typescript
export { YourNodeRunner } from './your-node.runner'

Add to libs/infra/execution/src/index.ts:

typescript
export { ..., YourNodeRunner } from './lib/runners'

Step 4: Write Tests

Create your-node.runner.spec.ts alongside your runner. Every runner must test:

  1. Node type declarationexpect(runner.nodeType).toBe('your_node_type')
  2. Happy path — valid config produces expected output
  3. Missing config — graceful error when config is absent
  4. Edge cases — empty upstream, invalid data shapes
  5. Security — no prototype pollution, no injection vectors
  6. AbortSignal (if your runner does async work)

Run tests:

bash
pnpm nx test infra-execution --testPathPattern="your-node.runner.spec" --no-coverage

Step 5: Security Checklist

Before submitting your PR, verify:

  • [ ] No eval(), Function() (except CodeNodeRunner which has special sandboxing)
  • [ ] No access to process, globalThis, require, import()
  • [ ] Input paths reject __proto__, constructor, prototype segments
  • [ ] If iterating arrays, enforce a maxItems cap (default: 1000)
  • [ ] If accepting user strings, validate length (max 10,000 chars)
  • [ ] If doing async work, respect ctx.signal for cancellation
  • [ ] Output is JSON-serializable (no functions, symbols, circular refs)
  • [ ] No network access unless the node's purpose requires it (and it's explicitly documented)

Step 6: Add Canvas UI Support (Optional)

To make your node appear in the workflow builder:

  1. Add an icon and color mapping in WorkflowCanvasNode.tsx
  2. Add a config form in WorkflowNodeConfigPanel.tsx
  3. Add the node to the "Add node" menu in WorkflowBuilderPage.tsx

Existing Runners as Reference

RunnerComplexityGood example for...
SetVariablesRunnerSimpleVariable mutations, no provider call
JsonTransformRunnerSimpleSafe path traversal, data extraction
SwitchRunnerMediumConditional logic, routing metadata
WaitDelayRunnerMediumAsync behavior, AbortSignal handling
ErrorCatchRunnerSimpleEngine integration metadata (__error_*)
LoopMapRunnerMediumArray processing, safety caps
CodeNodeRunnerComplexSandboxed execution, security validation
SubWorkflowRunnerMediumDelegation pattern, depth limiting

Design Principles

  1. Idempotent — runners may be retried; same input must produce same output
  2. Zero side effects — don't mutate global state; use variableMutations for scoped changes
  3. Fail gracefully — return error in output.data.error, don't throw unhandled exceptions
  4. No provider calls (unless your node wraps an external service — document it clearly)
  5. Report durationMs — 0 for instant operations, actual time for network/computation

PR Checklist

  • [ ] Type added to WorkflowNodeType union
  • [ ] Runner implements INodeRunner
  • [ ] Registered in runners/index.ts
  • [ ] Exported from libs/infra/execution/src/index.ts
  • [ ] Unit tests covering: happy path, missing config, edge cases, security
  • [ ] All existing tests still pass: pnpm nx test infra-execution --no-coverage
  • [ ] JSDoc comment on runner class explaining config schema and security model