@tbrandenburg/node-red-temporal-runtime
v0.1.6
Published
Temporal-backed execution runtime for Node-RED flows
Readme
@tbrandenburg/node-red-temporal-runtime
Run an ordinary Node-RED flow as a durable Temporal Workflow without replacing Node-RED's nodes, flow format, or route resolution.
[!WARNING] This package is early alpha software. It is an execution experiment, not a production-ready Node-RED distribution. Read Known limitations before using it for side effects.
Why this exists
Node-RED is excellent at describing and running message flows. Temporal is excellent at making long-running work durable. This runtime joins those two strengths at the message-delivery boundary:
Node-RED resolves the destination.
Temporal decides when that destination runs.The runtime boots a real, headless Node-RED process. A public Node-RED hook
captures each externally routed send(), records the destination Node-RED
already resolved, and suppresses the local hop. Temporal then schedules the
next node as an Activity.
Quick start
Requirements
- Node.js
>=22.9 - A running Temporal server, such as the Temporal CLI dev server
- A Node-RED flow exported as
flows.json
From the repository, the fastest path is:
npm install
make runThis boots a Temporal dev server, a Temporal-backed runner, and an ordinary Node-RED editor - all detached - with no pre-baked flow. Open the editor at http://localhost:1880, build or edit a flow, and press Deploy; it ships to the runner and executes durably on Temporal. Open the Temporal Web UI at http://localhost:8233 to inspect the Workflow and its Activity history.
To seed the runner's initial flow instead of starting empty, pass FLOW=:
make run FLOW=demo/flows.jsonThis is make run's thin wrapper around the "Use an existing Node-RED
editor with a Temporal runtime (issue #39)"
setup below - see that section for the manual equivalent and what each piece
does.
Stop everything with:
make stopCheck status at any time with:
make statusRun a flow directly
Start a Temporal dev server in one terminal:
temporal server start-devStart the combined worker in another:
node bin/node-red-temporal --flow demo/flows.jsonThe scheduled Inject in the demo starts Workflow Executions automatically. For an explicit one-off start, use:
node bin/node-red-temporal start \
--flow demo/flows.json \
--start-node n1 \
--input '{"payload":"hello"}'Installation
When installing the public npm package:
npm install @tbrandenburg/node-red-temporal-runtimeThe repository also contains the package at
packages/node_modules/@tbrandenburg/node-red-temporal-runtime/ for local
development. The repository's root npm install provides the pinned Node-RED
and Temporal dependencies used by the test suite.
How it works
flowchart LR
S["Node-RED source\nInject / MQTT / ..."]
C["Capture hook"]
W["Temporal Workflow"]
A["executeNode Activity"]
N["Live Node-RED node"]
R["Node-RED resolves destination"]
S --> C
C -->|"start Workflow"| W
W -->|"schedule"| A
A --> N
N --> R
R --> C
C -->|"destinationId"| WOwnership boundary
| Node-RED owns | Temporal owns | | --- | --- | | Node and config-node lifecycle | Durable downstream scheduling | | Node registry and implementations | Workflow progress and history | | Credentials and context/storage | Activity retries | | Source connections and timers | Worker-restart recovery | | Route resolution | Durable execution ordering | | Flow deploy/redeploy lifecycle | Orchestration state |
Node-RED's resolved routing is authoritative. The runtime does not rebuild a second routing engine from raw wires. A minimal wire graph exists only as a fallback for callers that provide a message without a captured destination.
Worker roles
The CLI supports one combined process or two independently deployed roles.
Combined worker
The default mode boots Node-RED, installs Capture, owns source ingress, and polls both Workflow and Activity tasks:
node bin/node-red-temporal --flow demo/flows.jsonSplit workers
The Workflow worker is deterministic and never boots Node-RED. The Activity worker owns the live Node-RED runtime and source ingress:
# Workflow role
node bin/node-red-temporal worker --role workflow \
--workflow-task-queue node-red-temporal-workflow
# Activity role
node bin/node-red-temporal worker --role activity \
--flow demo/flows.json \
--activity-task-queue node-red-temporal-workflowBoth roles accept the same Temporal connection settings. Use CLI flags or the corresponding environment variables:
| Setting | CLI flag | Environment variable | Default |
| --- | --- | --- | --- |
| Temporal address | --address | TEMPORAL_ADDRESS | 127.0.0.1:7233 |
| Namespace | --namespace | TEMPORAL_NAMESPACE | default |
| Workflow task queue | --workflow-task-queue | TEMPORAL_WORKFLOW_TASK_QUEUE | node-red-temporal |
| Activity task queue | --activity-task-queue | TEMPORAL_ACTIVITY_TASK_QUEUE | node-red-temporal |
Node execution timeout (issue #47)
One setting controls how long a single node invocation is allowed to run,
used consistently for both Capture's wait for the node's own Node-RED
done() call and the Temporal Activity's startToCloseTimeout (which is
derived as this value plus a small fixed safety margin, so a genuine node
timeout always surfaces as NODE_TIMEOUT rather than a raw Activity
timeout):
| Setting | CLI flag | Environment variable | Default |
| --- | --- | --- | --- |
| Node execution timeout | --node-timeout-ms | NODE_RED_TEMPORAL_NODE_TIMEOUT_MS | 60000 |
This is a single, generic setting - it is not node-type-specific, and a node that legitimately needs longer than 5 seconds (e.g. a Delay node configured for 10s) simply needs this timeout configured comfortably above its own delay, rather than being special-cased.
HTTP ingress (issue #58)
An opt-in HTTP ingress/egress transport lets an external HTTP caller start
(or start-and-wait-for) an executeFlow Workflow, without pretending the
HTTP connection itself is durable. It is a thin transport around
executeFlow - not a second Node-RED routing engine, and not a
HTTP In/HTTP Response node replacement (see
Not supported below).
Supported only for --role activity and --role combined (the default);
rejected outright for --role workflow, which never boots Node-RED.
Enabling it
| Setting | CLI flag | Environment variable | Default |
| --- | --- | --- | --- |
| HTTP ingress port | --http-ingress-port <port> | NODE_RED_TEMPORAL_HTTP_INGRESS_PORT | none - omit to disable entirely |
| HTTP ingress host | --http-ingress-host <host> | NODE_RED_TEMPORAL_HTTP_INGRESS_HOST | 127.0.0.1 |
| Sync route wait bound | --http-sync-timeout-ms <ms> | NODE_RED_TEMPORAL_HTTP_SYNC_TIMEOUT_MS | 60000 |
node bin/node-red-temporal --flow demo/flows.json \
--http-ingress-port 8080By default the server binds to 127.0.0.1 only. There is no built-in
authentication, TLS, CORS, or rate-limiting - put a reverse proxy or API
gateway in front of it for any exposure beyond loopback.
Routes
Two fixed routes exist, both POST, both keyed by Node-RED node id:
POST /_node-red-temporal/http/async/:startNodeId- startsexecuteFlowdurably and responds202 {"workflowId": "...", "reused": false}as soon as Temporal accepts the start. It never waits for the flow to finish.POST /_node-red-temporal/http/sync/:startNodeId/:resultNodeId- startsexecuteFlowwithresultNodeIdset, then waits (bounded by--http-sync-timeout-ms, default 60s) for the message delivered toresultNodeIdand maps it onto the HTTP response.
# async: fire-and-forget start
curl -X POST http://127.0.0.1:8080/_node-red-temporal/http/async/n1 \
-H 'Content-Type: application/json' \
-d '{"orderId": 42}'
# sync: wait for the flow's result
curl -X POST http://127.0.0.1:8080/_node-red-temporal/http/sync/n1/n2 \
-H 'Content-Type: application/json' \
-d '{"orderId": 42}'Request → message shape
Every request becomes one ordinary Node-RED message - no req/res,
socket, or stream ever crosses into Temporal:
{
"payload": "<parsed body>",
"http": { "method": "POST", "path": "/_node-red-temporal/http/async/n1", "query": {}, "headers": {} }
}Body parsing: JSON when Content-Type is a JSON type, otherwise UTF-8 text;
an empty body becomes payload: undefined; malformed JSON is rejected with
400. The raw body is capped at 256 KiB; anything larger is rejected
with 413 while still streaming in (never buffered unbounded first).
The following request headers are always stripped before the message is
built - they never reach the flow or Temporal history: authorization,
proxy-authorization, cookie, set-cookie, idempotency-key.
Idempotency
An optional Idempotency-Key request header (max 256 bytes, else 400) is
SHA-256 hashed into a bounded Workflow ID of the form
http:<mode>:<startNodeId>[:<resultNodeId>]:<hash-or-uuid>, started with
Temporal's REJECT_DUPLICATE Workflow ID reuse policy. A retried request
with the same key reuses the same logical Workflow ("reused": true in the
response) instead of starting duplicate work. The raw key itself is never
persisted into the Workflow ID or Workflow input.
Sync response mapping
For the sync route, the node immediately before resultNodeId controls the
HTTP response by setting fields on the message it sends:
msg.statusCode- an integer100-599; anything else falls back to200(the rest of the response is still built normally).msg.headers- a plain object copied onto the response. Hop-by-hop/framing headers (connection,keep-alive,transfer-encoding,content-length,upgrade,proxy-connection) are always stripped - Node computes framing itself.msg.payload-string→ sent as text;Buffer/Uint8Array→ sent as raw bytes; object/array/number/boolean → JSON-encoded with a defaultapplication/jsoncontent type;undefined→ empty body.
Sync error and timeout mapping
- Workflow failure,
FLOW_RESULT_MISSING(the marker node was never reached), orFLOW_RESULT_AMBIGUOUS(the marker node was reached more than once) all map to500with{"error": "...", "type": "...", "workflowId": "..."}. - If the sync wait exceeds
--http-sync-timeout-ms, the response is504with{"workflowId": "..."}. The Workflow keeps running - it is never cancelled or terminated, only the HTTP wait ends. - If the client disconnects before the Workflow settles, the same applies: the Workflow is left running untouched: an HTTP disconnect never cancels/terminates it.
Durability caveats
- The HTTP socket is never durable. A process restart mid-request loses
the caller's connection, but never the Workflow itself - once
executeFlowhas started, it continues and completes independently of the ingress process that started it. - Forwarded data becomes Temporal payload/history data. Headers, query parameters, and body content are all persisted into Workflow history exactly like any other message. Use Temporal's own Payload Codec/encryption for sensitive data - this feature introduces no codec or encryption subsystem of its own.
Not supported (issue #58)
- Stock Node-RED
HTTP In/HTTP Responsenode compatibility - now supported separately via a transport-edge bridge, see Stock HTTP In / HTTP Response (issue #75) below. - Live
msg.req/msg.resobjects. - WebSockets or Server-Sent Events.
- Multipart/file uploads.
- A generic Workflow query/status API.
Stock HTTP In / HTTP Response (issue #75)
Stock Node-RED authoring - HTTP In -> ordinary nodes -> HTTP Response -
works on the Activity/combined role out of the box, with no new route, no
new CLI flag, and no opt-in required (unlike issue #58's HTTP
ingress, which is a separate, explicitly-enabled
transport). Node-RED still owns HTTP route registration, method matching,
body parsing, cookies, and response semantics entirely through its own
stock nodes; this runtime only bridges the transport edges around a durable
executeFlow Workflow - it is not a second HTTP subsystem and not a
replacement HTTP In/Response node.
live socket plain durable data live socket
| | |
HTTP In -- snapshot -- Temporal Workflow -- response descriptor -- HTTP response- When a request reaches a stock
HTTP Innode, only a plain snapshot of the request (method, sanitizedheaders,params,query,body,originalUrl,path,hostname,ip,protocol,secure) is handed to Temporal. The live Expressreq/res- sockets, streams, functions - are never part of Workflow/Activity input/output/history. The same sensitive header stripping as issue #58 applies (authorization,proxy-authorization,cookie,set-cookie,idempotency-key). - When the Workflow reaches a stock
HTTP Responsenode, the REAL, unmodified node executes against a small Activity-local response recorder implementing only the Express response methods it actually calls (set,get,status,cookie,clearCookie,send,jsonp) - not a reimplementation of the node's own status/header/cookie/JSON logic. The recorder's plain, serializable descriptor is the only thing that crosses back out of the Activity. - The Workflow requires exactly one HTTP Response descriptor by
completion: zero fails non-retryably with
HTTP_RESPONSE_MISSING, more than one (e.g. two conditional branches both responding) fails non-retryably withHTTP_RESPONSE_AMBIGUOUS. No static "which node responds" configuration is required - whichever HTTP Response node actually executes wins. - Bounded-wait semantics match issue #58 exactly: the process-local
ingress handler waits for the Workflow's result, bounded by the SAME
--http-sync-timeout-msvalue configured for issue #58's sync route (a timeout never cancels/terminates the Workflow, only ends the HTTP wait); a Workflow start failure maps to503; a Workflow/Activity failure maps to500; a client disconnect before the Workflow settles suppresses the write entirely, leaving the Workflow running untouched. - Live Express API calls outside the stock HTTP Response node's own
contract are not supported - e.g. a Function node directly calling
msg.req.socket,msg.req.pipe(...), ormsg.res.send(...)itself. Only the ordinary Node-RED pattern of mutatingmsg.payload/msg.statusCode/msg.headers/msg.cookiesbefore a stock HTTP Response node is supported. - Cookie support is a small, best-effort subset (name/value plus
path,expires,maxAge,httpOnly,secure,sameSite), not a fullcookie-package-accurate reimplementation. - No socket registry, no correlation map, no worker-affinity scheduler:
the live response is already reachable from the process-local ingress
handler's own closure, and Node-RED's own resolved
preRouterouting remains authoritative throughout - Temporal never re-derives routing from the raw wire graph.
Use an existing Node-RED editor with a Temporal runtime (issue #39)
The command sequences above all treat node-red-temporal as the thing you
run instead of Node-RED, pointed at a static flows.json file. That is a
development/debug convenience, not how this package is meant to be used
day to day.
The primary, supported way to adopt Temporal-backed execution is to keep
using your existing, ordinary Node-RED editor exactly as before, and
have its normal Deploy button ship the flow to a separate
node-red-temporal execution runner. Two separate roles are involved:
- Editor instance ("A") - an ordinary Node-RED install. Same URL, same editor, same palette, same Deploy button. Its own local flows stay stopped and never execute - it is design-time only.
- Runner ("B") - a
node-red-temporalprocess (this package) started with its Admin API exposed and its editor disabled (--admin-port, see below). It receives A's deployments and executes them for real, through the existing Temporal-backed Activity Worker.
Node-RED A (design time) Node-RED-Temporal B (execution time)
http://localhost:1880 Deploy http://localhost:1881 (Admin API only)
normal editor + Deploy ───────────▶ disableEditor:true
flows kept STOPPED locally Capture + executeNode Activity Worker
└── TemporalHow it works
A small adapter package installed into A's userDir wraps A's existing
Node-RED storageModule (whatever it already uses - the built-in
filesystem store or a custom one). It changes nothing about how A stores
its own flows/credentials locally; it only ADDS a remote deployment step:
- A's normal Deploy button calls Node-RED's own Admin API on A, which
calls
runtime.flows.setFlows(), which calls the storage module'ssaveFlows()- completely standard Node-RED. - The adapter's
saveFlows()first delegates to A's real storage module (so A's local, on-disk flow state is saved exactly as it always was). - It then reads A's current, real, encrypted credential bundle via the
delegate's
getCredentials(). - It performs one full (
Node-RED-Deployment-Type: full) Admin API v2POST /flowsrequest -{ flows, credentials }- straight to B. - B's own
runtime.flows.setFlows()deploys/redeploys exactly like a normal local Deploy would, and B generates its own revision (A's revision is never forwarded).
If step 4 fails for any reason (B unreachable, rejected, wrong token,
wrong credentialSecret, ...), the adapter's saveFlows() rejects, so
Node-RED's own Deploy request surfaces a normal error in A's editor - there
is no false "Deploy succeeded" when the remote deploy didn't actually
happen. A's local save may already have completed; simply press Deploy
again once B is reachable.
1. Start the runner (B)
node bin/node-red-temporal worker --role activity \
--flow demo/flows.json \
--admin-port 1881 --admin-host 0.0.0.0(--role combined also accepts --admin-port if you want one process to
be both Activity worker and Workflow worker, today's default topology; see
the split-role section above if you want them separate.) This boots a real
Node-RED runtime with disableEditor: true - the Admin API's POST /flows
route is served, but no editor HTML/static assets are - and installs
Capture/source-ingress exactly as any other Activity-role worker does. The
node types used by the deployed flow must already be installed/available
on this runner, the same requirement as any Node-RED instance.
Using a stable userDir and normal Node-RED settings.js (issue #46)
By default the runner boots Node-RED from a fresh temporary directory that
is discarded on exit. For a real deployment, give it a stable userDir (and
optionally a normal Node-RED settings.js) instead:
node bin/node-red-temporal worker --role activity \
--flow ./seed.json \
--user-dir /srv/node-red-temporal \
--settings /srv/node-red-temporal/settings.js \
--admin-port 1881--user-dir <path>(envNODE_RED_TEMPORAL_USER_DIR): a real, persistent Node-REDuserDir. Reusing the same path across restarts keeps installednode-red-contrib-*modules, credentials, and any persistent context store intact - this is threaded straight intobootstrap()'s existingoptions.userDir, no new mechanism.--settings <path>(envNODE_RED_TEMPORAL_SETTINGS): path to an ordinary Node-REDsettings.js(a CommonJS module exporting an object, exactly like the stock Node-RED CLI expects). It isrequire()'d as-is and passed through unchanged asbootstrap()'soptions.settings- sofunctionGlobalContext, a persistentcontextStorage(e.g. the built-inlocalfilesystemstore), TLS options, and any other normal Node-RED setting all work without node-red-temporal-specific handling.- Installing contrib nodes for the runner works exactly like a normal
Node-RED install -
cdinto the runner'suserDirandnpm installthe package:
Any node type used by a deployed flow must be installed on the execution runner (B), not just the editor (A) - the runner is what actually executes the flow's nodes.cd /srv/node-red-temporal && npm install node-red-contrib-foo - Omitting both options preserves today's behavior: a fresh temp
userDirper process, discarded on exit. make runuses a persistent, gitignored.node-red-temporal/runner-userdir/for runner B by default, analogous to editor A's.node-red-temporal/editor-userdir/.
2. Install the adapter into the existing editor's userDir (A)
cd ~/.node-red # A's existing userDir
npm install @tbrandenburg/node-red-temporal-runtime3. Configure A's settings.js
// ~/.node-red/settings.js
const { createRemoteDeployStorage } = require("@tbrandenburg/node-red-temporal-runtime/lib/remoteDeployStorage");
const localfilesystem = require("@node-red/runtime/lib/storage/localfilesystem");
module.exports = {
// ... your existing settings ...
// Required: A and B must share the SAME credentialSecret so B can
// decrypt the credential bundle A forwards. Never rely on Node-RED's
// auto-generated per-instance key here.
credentialSecret: process.env.NODE_RED_CREDENTIAL_SECRET,
// Wrap whatever storage module you already use (the built-in
// localfilesystem store here, or your own custom one) - it remains
// fully authoritative for A's local flows/credentials/settings.
storageModule: createRemoteDeployStorage(localfilesystem, {
target: process.env.NODE_RED_TEMPORAL_TARGET, // B's base URL
token: process.env.NODE_RED_TEMPORAL_DEPLOY_TOKEN // optional, forwarded as `Authorization: Bearer <token>`
}),
// Keep A design-time only: `runtimeFlowState: "stop"` is what actually
// stops @node-red/runtime from starting A's own copy of the flow;
// `runtimeState: { enabled: false, ui: false }` additionally hides the
// local start/stop controls/endpoint (it does not by itself stop
// execution).
runtimeFlowState: "stop",
runtimeState: { enabled: false, ui: false }
};Local development example (target):
export NODE_RED_TEMPORAL_TARGET="http://localhost:1881"
export NODE_RED_CREDENTIAL_SECRET="a-shared-dev-secret"Remote/cloud example:
export NODE_RED_TEMPORAL_TARGET="https://node-red-runtime.example.com"
export NODE_RED_TEMPORAL_DEPLOY_TOKEN="$(cat /run/secrets/runner-admin-token)"
export NODE_RED_CREDENTIAL_SECRET="$(cat /run/secrets/shared-credential-secret)"4. Restart A, then use it exactly as before
Restart the existing Node-RED process so settings.js is reloaded. Open
the same editor URL as before (http://localhost:1880). Confirm A's own
flows are stopped/design-only (runtimeFlowState: "stop"), then
press the normal Deploy button. Verify the deployment reached B (its
logs print activity worker connected at boot and each redeploy's revision
is visible via GET http://<B>/flows), and that execution now shows up in
Temporal (new Workflow Executions on scheduled/triggered nodes).
Disabling / reverting
Remove or comment out the storageModule/credentialSecret/runtimeFlowState/runtimeState
block from A's settings.js, restart A, and it goes back to being a normal,
fully local Node-RED instance. Uninstalling the
@tbrandenburg/node-red-temporal-runtime package from A's userDir is
optional and has no effect as long as settings.js no longer references it.
What happens when the runner is unreachable
Every Deploy attempt on A performs a live remote call to B. If B is down,
unreachable, or returns a non-2xx response, the adapter's saveFlows()
rejects and Node-RED's Deploy request in A's editor shows a real error -
never a false "Deploy succeeded". A's local flow file may already have been
saved; simply retry Deploy once B is reachable again.
Debug output and node status in A's editor (issue #59)
Because B executes the flow, its nodes are the ones calling node.status()
and node.warn/log/error (which Debug nodes render). By default A's Debug
sidebar and canvas status dots stay empty - A never ran the flow itself.
A small, one-way (B → A) observability bridge fixes this without any
execution/scheduling changes: B streams its native node-status events and
Debug-only comms events (topic === "debug") over a small NDJSON HTTP
endpoint on its existing Admin API
(GET /_node-red-temporal/runtime-events, protected by the same Admin
auth/token already configured above). A's receiver re-emits those same
native events on A's own @node-red/util events singleton, so A's
unmodified Debug sidebar and status renderer just work.
This is transient/observability-only: Debug messages are lost if A is disconnected when they happen (no queue/history), node-status keeps only the latest value per node in B's memory (not durable - cleared on a B restart), and a slow/unreachable A can never block or slow down B's execution.
Add one line to A's settings.js, reusing the same target/token
already given to createRemoteDeployStorage:
// ~/.node-red/settings.js
const { createRemoteDeployStorage, createRuntimeEventsReceiverForTarget } =
require("@tbrandenburg/node-red-temporal-runtime/lib/remoteDeployStorage");
const runtimeEventsTarget = process.env.NODE_RED_TEMPORAL_TARGET;
const runtimeEventsToken = process.env.NODE_RED_TEMPORAL_DEPLOY_TOKEN;
createRuntimeEventsReceiverForTarget({
target: runtimeEventsTarget,
token: runtimeEventsToken
}).start();
module.exports = {
// ... storageModule/credentialSecret/runtimeFlowState/runtimeState as above ...
};Non-goals (deliberately out of scope): Temporal Activity retry/attempt rendering, Workflow replay visualization, a custom canvas overlay, Debug history/persistence, or any bidirectional/control channel back to B - this is a one-way transport for two existing, native Node-RED events only.
Proxying runtime-affine editor actions to B (issue #61)
Deploy is not the only editor action that is affine to the runtime executing the flow. Stock Node-RED also serves a small set of Admin API routes straight out of the SAME process that is running the flow:
- manual Inject (
POST /inject/:id); - Debug enable/disable (
POST /debug/enable,/debug/disable,/debug/:id/enable,/debug/:id/disable- NOT the Debug sidebar's static/debug/view/*assets); - live Context inspection/delete (
GET/DELETE /context/...).
In the split A/B topology these touch live node/context state that only
exists on B. createRemoteRuntimeAdminProxy() proxies exactly this
allow-list from A to B - it is intentionally NOT a generic reverse proxy:
every other route (/flows, /nodes, /settings, /debug/view/*, unknown
paths, arbitrary contrib-node routes) always falls through to A's own local
Admin API unchanged.
Reuse the SAME remoteRunner config object as createRemoteDeployStorage -
there is deliberately no separate target/token configuration for Deploy vs.
runtime actions:
// ~/.node-red/settings.js
const { createRemoteDeployStorage } = require("@tbrandenburg/node-red-temporal-runtime/lib/remoteDeployStorage");
const { createRemoteRuntimeAdminProxy } = require("@tbrandenburg/node-red-temporal-runtime");
const localfilesystem = require("@node-red/runtime/lib/storage/localfilesystem");
const remoteRunner = {
target: process.env.NODE_RED_TEMPORAL_TARGET, // B's base URL
token: process.env.NODE_RED_TEMPORAL_DEPLOY_TOKEN // optional; shared with the proxy below
};
module.exports = {
// ... your existing settings ...
storageModule: createRemoteDeployStorage(localfilesystem, remoteRunner),
// Compose with any existing middleware - does not replace it.
httpAdminMiddleware: [
/* any existing middleware, */
createRemoteRuntimeAdminProxy(remoteRunner)
]
};For each allow-listed request, the proxy first runs A's OWN existing
@node-red/editor-api auth.needsPermission(<permission>) middleware (the
identical check the stock routes use: inject.write, debug.write,
context.read, context.write) - the browser's A credentials/token are
checked against A exactly as before. Only once that succeeds is the request
forwarded to B, authenticated with B's own configured token (the
browser's A token is never forwarded, and no second permission system is
introduced). Request method, path, query string, and body bytes are
forwarded byte-for-byte; B's response status/body/content type are mirrored
back unchanged - the Inject/Debug/Context payloads are never interpreted.
This is request/response only: there is no background connection, retry
loop, or queue, and it is not coupled to Temporal execution in any way. If B
is unavailable for an allow-listed action, the browser gets a 502/503
for that one interactive action (Deploy and every other Admin API route on
A are unaffected) - the user simply retries.
Automated CI only exercises this against a fake HTTP server (see
remoteRuntimeAdminProxy_spec.js); booting a real editor A + runner B via
make run and clicking Inject/Debug/Context in the browser is the manual
acceptance path.
Screenshots: #59/#61 in action
Editor A's stock, unmodified Debug sidebar and canvas node-status after clicking Inject in the browser - the message and status were produced by runner B, relayed back over issue #59's bridge:

The same click, proxied through issue #61 to runner B, as a real Temporal
executeFlow Workflow in the Temporal Web UI:

Recovery demo
What works today
The tested early-alpha corpus includes:
- autonomous source ingress;
- fan-out, multiple outputs, and repeated sends;
- Link, Catch, and Complete routing;
- Join/fan-in and finite loops;
- nested subflows;
- credentials, config nodes, and Node-RED context;
- configurable persistent context stores;
- flow redeploy without restarting the worker;
- explicit
flowVersionprotection for in-flight Workflows; - combined and split Workflow/Activity workers;
- worker restart and Workflow recovery.
See COMPATIBILITY.md for the measured matrix and its
scope. The matrix is evidence for a small representative corpus, not a claim
that every Node-RED node or community flow is supported.
Known limitations
These are behavioral boundaries, not configuration options:
- Activities are at-least-once. A worker killed during a side-effecting Activity can execute that Activity again. The recovery demo is intentionally killed between Activities, not during one.
- Flow versions do not migrate. A redeploy changes the content-hash
flowVersion; an in-flight Workflow pinned to the old version fails withFLOW_VERSION_MISMATCH. - Context is not Temporal state. In-memory Node-RED context is lost on a worker restart. Configure a persistent Node-RED context store when required.
- Hooks are process-global. Run one Capture instance per Activity-worker process.
- HTTP: outbound and stock inbound are both supported.
- ✓ outbound
http request— an ordinary Activity, subject to the at-least-once semantics above. - ✓ inbound
http in -> ... -> http response(issue #75) — supported through a process-local transport-edge bridge (see Stock HTTP In / HTTP Response). Live, process- bound Expressreq/resobjects are still never serialized into Temporal history or made durable themselves - only a plain request snapshot and response descriptor cross the Workflow/Activity boundary; the HTTP socket remains explicitly best-effort and process-local.
- ✓ outbound
- Throughput is uncharacterized. There is no production sizing, batching, autoscaling, or latency guidance yet.
- Legacy (pre-1.0) input handlers fail fast, they do not hang (issue #53).
A node whose
inputhandler is written in the pre-1.0 style -node.on('input', function(msg) {...}), 1 or 2 declared parameters, nodone()- does not participate in Node-RED's own completion tracking (@node-red/runtime'sNode.js), so this runtime cannot determine a durable Activity-completion signal for it. Its Activity now fails immediately witherror.code === "LEGACY_NODE_NO_DONE"instead of hanging for the full node-execution timeout. Legacy Node-RED nodes that do not participate in the(msg, send, done)completion contract cannot currently provide an exact durable Activity-completion signal - this is a real compatibility boundary of the Activity-per-node execution model, not a bug in those contrib packages. Node-RED's own subflow instance nodes are unaffected (their internal routing is recognized structurally, not by node type) and continue to work exactly as before. SeeCOMPATIBILITY.mdfor the full finding and re-run evidence.
Message serialization boundary
Messages crossing a Workflow or Activity boundary use Temporal's default data converter. The runtime does not normalize messages before conversion.
| Value | Result |
| --- | --- |
| Strings, numbers, booleans, null | Supported unchanged |
| Plain objects and arrays | Supported with JSON semantics |
| Buffer | Bytes preserved, decoded as Uint8Array rather than Buffer |
| Date | Decoded as an ISO-8601 string |
| undefined | Supported; object properties with undefined are dropped |
| Error | Decoded as {}; non-enumerable error fields are lost |
| Functions | Bare functions fail; function-valued properties are dropped |
| Circular objects | Conversion fails with a Temporal ValueError |
| Sockets and streams | Not durable; only an inert enumerable snapshot may remain |
Treat live handles, request objects, streams, and circular structures as outside the durable message boundary.
Package map
| Module | Responsibility |
| --- | --- |
| lib/bootstrap.js | Boots Node-RED and computes the content-hash flowVersion |
| lib/capture.js | Captures resolved routes and completion/error events |
| lib/activities.js | Delivers one message to one live node |
| lib/workflows.js | Deterministically drains captured destinations |
| lib/worker.js | Creates combined, Workflow, and Activity workers |
| lib/wireGraph.js | Fallback nodeId -> wires transform |
| bin/node-red-temporal | Worker and explicit Workflow-start CLI |
Development
From the repository root:
npm install
npm run mocha:core
npm testRuntime tests live under
test/unit/@tbrandenburg/node-red-temporal-runtime/. The implementation is
kept under the @tbrandenburg package tree so the upstream Node-RED packages
remain unchanged.
To publish the runtime package:
make publishTo create a GitHub release for the runtime package:
make release BUMP=PATCHThe release target creates a temporal-v<version> tag and GitHub release. It
requires a clean worktree and does not publish to npm automatically.
Project links
- Project overview and design
- Compatibility matrix
- GitHub releases
- Issue tracker
- Temporal documentation
- Node-RED documentation
License
Apache-2.0. See LICENSE.
