HTTP nodes for Node-RED, built with
@bonsae/nrg on the platform-native
fetch API — no
request, axios, or body-parser dependency. Their config surfaces mirror
Node-RED's core http request / http in / http response nodes, so a flow
author migrating over sees the same knobs.
| Node | Description |
|---|---|
| HTTP Request | Make outbound HTTP requests with native fetch — method, mustache URL, auth, return type; response on msg.output |
| HTTP In | Listen on a path of Node-RED's HTTP server and emit one message per incoming request |
| HTTP Out | Write the reply back on the live socket for a request started by HTTP In |
Install into the directory that holds your Node-RED package.json (usually
~/.node-red):
npm install @bonsae/node-red-httpEverything the nodes need is pulled in automatically. Restart Node-RED after
installing; the three nodes then appear under the network category
(HTTP request, HTTP in, HTTP out) in the palette.
Two independent flows: a server (http-in → http-out round trip) and a
client (inject → http-request → debug).
Server — an http-in on GET /hello wired straight to an http-out. Every
request to /hello gets a reply written back on its own live socket:
[http-in GET /hello] --> [http-out]
Client — call an external API and route the parsed JSON onward:
[inject] --> [http-request GET https://api.example.com/thing] --> [debug]
Both flows ship as ready-to-import examples. In the Node-RED editor, open
Menu → Import → Examples → @bonsae/node-red-http and pick HTTP endpoint
or HTTP request — or browse them in
src/resources/examples/. Deploy, hit the inject
button to fire the outbound request, and curl http://localhost:1880/hello to
exercise the server pair.
Two nrg conventions make these nodes work; understanding them is enough to wire anything.
1. Sent values ride under msg.output. When an nrg node sends a message, its
values are wrapped under msg.output (with a provenance chain), not spread at
the top level. So http-request emits
msg.output = { statusCode, headers, payload, responseUrl }, and a downstream
http-out reads its reply from msg.output when wired from another nrg node
(falling back to the top-level msg for a raw/injected message).
2. The live response rides the private channel, not the wire. A live
ServerResponse socket is a non-serializable object — it cannot ride the message
wire (Node-RED clones messages), and threading a correlation id through every
intermediate node is brittle. Instead:
http-inparks the livereson nrg's private channel —send("out", payload, { private: { res } })— and emits only a clone-safe request snapshot on the public wire.- The private channel is off-the-wire and package-scoped, keyed by the
_msgidnrg mints for that send. nrg carries_msgidforward across every node, so the entry stays reachable no matter how the flow is wired. http-outrecovers the socket withmsg[Channels].private.res(importingChannelsfrom@bonsae/nrg/server), writes the reply, thendelete msg[Channels].private.resto claim the socket — a secondhttp-outfor the same message finds nothing, so a request is answered exactly once.
Constraints. The private channel is package-scoped, so
http-inandhttp-outmust come from this same package to share it — you cannot bridge to a corehttp responsenode. If a request is never answered,http-inforce-ends the socket with HTTP 504 after 5 minutes (RESPONSE_TIMEOUT_MS, currently a fixed constant, not configurable) so an abandoned socket can't hang the client.
Because the channel is keyed per request, concurrent requests never cross responses — each caller is answered on its own socket even when completion order is scrambled.
Type: http-request · Category: network
Sends an outbound HTTP request via the native fetch API and emits the response
out the response port. On each input message the node builds and dispatches the
request, then reports status (blue while sending, green on res.ok, yellow with
the status code otherwise, red on error).
URL resolution: msg.url wins over the configured url; the result is rendered
through {{mustache}} substitution (tags pulled from the message, unresolved →
empty string) and trimmed. An empty URL throws No url specified; a scheme-less
URL is prefixed with http:// (not https://).
Payload handling depends on payload (paytoqs) and only runs when
msg.payload is neither undefined nor null:
- query and payload is an object → each entry is appended to the URL's search params.
- body, or ignore when the method is not
GET/HEAD→ the payload is serialized into the request body. Strings /Buffer/Uint8Arrayare sent as-is; any other value isJSON.stringify'd and — only then, if nocontent-typeheader is set —Content-Type: application/jsonis defaulted.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | "" |
Editor label for the node instance. |
method |
select | GET |
HTTP method: GET · POST · PUT · DELETE · HEAD · OPTIONS · PATCH · use. use reads msg.method at runtime (defaulting to GET). |
url |
typed input (str / msg) |
{ type: "str", value: "" } |
Request URL. Supports {{mustache}} tags resolved against the message. |
ret |
select | txt |
Return body as: txt = UTF-8 string · bin = Buffer · obj = parsed JSON (falls back to raw text if the body isn't JSON, {} if empty). |
paytoqs |
select | ignore |
Send msg.payload as: ignore = default · query = append to URL · body = request body. |
headers |
typed input (json / msg) |
{ type: "json", value: "{}" } |
Static request headers (an object). Merged with msg.headers, which overrides. |
authType |
select | "" |
Authentication scheme: "" (none) · basic · bearer. |
persist |
boolean | false |
Keep the TCP connection alive between requests (passed to fetch as keepalive). |
insecureHTTPParser |
boolean | false |
Present in the form only — not read anywhere in the node code, so it has no runtime effect (fetch does not expose it). |
senderr |
boolean | false |
Send request errors to the output instead of failing the node (see Ports). |
| Field | Description |
|---|---|
user |
Username for basic auth. Used to build the Basic token. Stored encrypted. |
password |
Password for basic auth (masked input). Stored encrypted. |
bearerToken |
Token for bearer auth (masked input). Sent as Authorization: Bearer <token>, only when a token exists. Stored encrypted. |
Auth application by authType: basic → Authorization: Basic base64(user:password);
bearer → Authorization: Bearer <bearerToken> (only if a token is set);
"" (none) → no header.
Input — Input<Port<{ payload?; method?; url?; headers? }>> (label: Request).
The following msg properties override config at runtime:
msg property |
Effect |
|---|---|
msg.method |
Overrides config.method only when config.method === "use" (else falls back to GET). |
msg.url |
Takes precedence over the configured url typed input. |
msg.headers |
Merged over (overrides) the configured headers. |
msg.payload |
Request body / query-string source, per paytoqs. |
Output — port response (label: Response):
Port<{ statusCode: number; headers: Record<string,string>; payload: string | Buffer | unknown; responseUrl: string }>.
insecureHTTPParseris in the schema and form but never referenced in code — it has no runtime effect.- A scheme-less URL is auto-prefixed with
http://, nothttps://. paytoqs: "ignore"still sendsmsg.payloadas the body for non-GET/HEADmethods;GET/HEADnever carry a body underignore.- With
senderr: true, a failed request produces a normal output message withstatusCode: 0instead of erroring the node:{ statusCode: 0, headers: {}, payload: <error message>, responseUrl: url }. - Only non-string/
Buffer/Uint8Arrayobject payloads areJSON.stringify'd (and only then isContent-Typedefaulted). tlsandproxyconfig-node references from corehttp requestare intentionally not supported — the nativefetchAPI has no equivalent.
Type: http-in · Category: network
A source node: it registers a route on Node-RED's user HTTP server
(RED.httpNode) and emits one message per incoming HTTP request. There is no
wire input.
On created() it lowercases the method (default get) and normalizes url to a
leading-slash route (hello → /hello). An empty/whitespace path sets a red
"missing path" status and throws http-in: no url path configured. If
RED.httpNode isn't available it sets a red "no HTTP server" status and
throws. On success the status shows a green dot <METHOD> <url>.
Per request it builds query (from req.query, else parsed from the URL) and,
for POST/PUT/PATCH/DELETE, reads and parses the body content-type-aware
(application/json → JSON.parse with raw-string fallback;
application/x-www-form-urlencoded → object from URLSearchParams; text/* →
UTF-8 string; otherwise the raw Buffer; undefined if empty). Other methods
get body = undefined.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | "" |
Editor label for the node instance. |
method |
select | get |
HTTP method to listen for: get · post · put · delete · patch. |
url |
string | "" |
Path to listen on, e.g. /hello or /user/:id. Normalized to a leading slash. |
Input — none (this is a source node; the input generic is never, so there
is no wire input — it is driven only by incoming HTTP requests).
Output — port out (label: Request):
{
payload: unknown; // parsed query for GET, otherwise the parsed request body
req: {
method: string;
url: string;
headers: IncomingMessage["headers"];
query: Record<string, unknown>;
params: Record<string, string>;
body: unknown;
}
}The live res is not on the wire — it is passed on the private channel
({ private: { res } }) keyed by _msgid for http-out to recover downstream.
- Body is only read for
POST/PUT/PATCH/DELETE; forGET(and other methods) the emittedpayloadis the query object andbodyisundefined. - Unanswered requests are force-ended with HTTP 504 after 5 minutes; the
timer is
unref()'d and cleared on the response"close"event. closed()removes the route by directly splicingRED.httpNode._router.stack(Express 4 has no public route-removal API) — the same technique the corehttp innode uses — so redeploys don't stack duplicate routes.
Type: http-out · Category: network
Writes the HTTP reply for a request started by an http-in node. It is a
sink node: it consumes the incoming message, sends the response over the live
socket, and produces no wire output.
On input(msg) it reads the live ServerResponse off
msg[Channels].private.res. If none is present (already answered, expired, or
not wired from an http-in in this package) it sets a yellow "no request"
status, warns, and returns without sending. Otherwise it immediately claims the
socket (delete msg[Channels].private.res), derives the reply values, and writes
the response — green <statusCode> on success, red "response failed" on error.
Reply derivation:
- source
src = msg.output ?? msg— values are read frommsg.outputwhen present (wired from another nrg node), otherwise from the top-levelmsg(a raw/injected message). - statusCode
Number(src.statusCode ?? config.statusCode) || Number(config.statusCode) || 200— an invalid/blank status falls back to config, then to200. - headers resolved
config.headersfirst, thensrc.headersoverrides. - body
src.payload→undefined/nullbecomes an empty body; aBufferis sent as-is; any other object isJSON.stringify'd (and, if nocontent-typeheader is set case-insensitively,Content-Type: application/jsonis added); otherwiseString(body).
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | "" |
Editor label for the node instance. |
statusCode |
string | "" |
Status code. Blank → msg.statusCode, else 200. |
headers |
typed input (json / msg) |
{ type: "json", value: "{}" } |
Response headers (an object). Resolved config headers are merged first, then message headers override. |
Input — Input<Port<{ payload?; statusCode?; headers?; output?; [key]: unknown }>>
(label: Response). Reply-source precedence:
| Source | Effect |
|---|---|
msg.output |
Preferred source when wired from another nrg node (src = msg.output ?? msg). |
top-level msg |
Fallback source for a raw/injected message. |
src.statusCode |
Overrides config.statusCode (may be number or string). |
src.headers |
Merged over (overrides) resolved config.headers. |
src.payload |
The response body. |
Output — none (sink node; the output generic is never).
- If no live response is on the private channel it does not error — it warns,
shows a yellow
"no request"status, and returns silently. - The socket is claimed via
delete msg[Channels].private.resbefore writing, so a secondhttp-outfor the same_msgidfinds nothing (answered exactly once). statusCodeconfig is a string; a blank/invalid value falls throughNumber(...)to config, then to200.Bufferpayloads are sent as-is;null/undefinedbecome an empty body; non-object non-Bufferbodies areString()-coerced.Content-Type: application/jsonis auto-added only for object bodies when nocontent-typeheader is already set.
| Version | |
|---|---|
| Node-RED | 5.x |
@bonsae/nrg |
^0.41.0 |
| Node.js | >= 22 |
| pnpm (for development) | >= 10.11.0 |
Registration is handled entirely through @bonsae/nrg (defineModule), not the
classic Node-RED node-red.nodes map.
pnpm install
pnpm build # bundles the nodes into dist/
pnpm dev # boots a Node-RED editor with the nodes loaded
pnpm validate # tsc (server/client/tests) + eslint + prettier
pnpm test # server unit + integration (real HTTP round-trip)
pnpm test:server:unit
pnpm test:server:integrationLabels and auto-generated help docs are available in:
- English (en-US)
- German (de)
- Spanish (es-ES)
- French (fr)
- Japanese (ja)
- Korean (ko)
- Portuguese (pt-BR)
- Russian (ru)
- Chinese Simplified (zh-CN)
- Chinese Traditional (zh-TW)
MIT © Allan Oricil