Skip to content

References & Ownership

Putting a live object — a stream, socket, client, event emitter, or a resolver function — on a port and having it survive a fan-out, with Ref and Own.

Most of what a node puts on a port is plain data — an id, a number, a parsed body. It rides the message record, and when one output wires to several nodes Node-RED deep-clones it to each branch so the branches can't disturb one another. Plain data clones fine.

A live object does not. On a fan-out Node-RED hands the first branch the original message and a structural deep clone to every other branch — the same as running structuredClone(msg) once per extra wire. A Readable, a socket, a DB client, an EventEmitter, or a function comes out the other side broken: its buffers, its file descriptor, its prototype, its closures don't survive a structural clone.

So the moment you want a live object on a port, you have to answer one question — the same one you'd answer handing a shared object to several functions in code:

Should every branch share the one object, or should each get its own?

Ref and Own (from @bonsae/nrg/server) are the two answers. You declare one on the port, and send requires the matching constructor — the port is the contract, and the wire check keeps the producer and every reader in agreement.

The decision

Think of the fan-out as calling N functions with the same input. What should each call receive?

What you're passingIn plain code you'd…On the portEach branch gets
plain, serializable datapass it — it gets copiedthe valueits own deep copy
a live object that's safe to sharepass a reference / a singletonRef<T>the same instance
a live object each consumer needs its own ofcall a factory / new per callOwn<T>a fresh instance

When in doubt, ask the code question: would I hand these callees the exact same object, or construct a new one for each? That answer is your wrapper.

Ref — one shared instance

Ref(value) is a shared reference: every branch gets the same object, uncloned — exactly like passing one pointer to several functions.

typescript
type Out = Outputs<{ out: Port<{ _db: Ref<DbClient> }> }>;

this.send("out", { _db: Ref(this.#client) });

// consumer — Ref is transparent to use; msg._db IS the client:
async input(msg: Input<Port<{ _db: Ref<DbClient> }>>) {
  const rows = await msg._db.query("select 1");
}

Use Ref when sharing is the point — a DB client or connection pool, an HTTP agent, an EventEmitter, an in-memory cache, or a correlation resolver two paired nodes hold between them. You want one pool, one set of listeners, one live handle.

Trade-off — it's shared, with everything that implies. Every branch sees one object, so a mutation in one branch is visible in the others (aliasing), and a single-consumer resource gets contended: Ref a Readable and the first branch to read drains it, leaving the rest empty. The usual shared-mutable-state caution applies — it's a reference, not a copy.

Own — a fresh instance per branch

Own(make) is a factory: you hand it a function that builds the object, and each branch gets its own instance from it. It's list.map(() => new Thing()) — one per branch.

typescript
type Out = Outputs<{ out: Port<{ _rows: Own<Readable> }> }>;

this.send("out", { _rows: Own(() => createReadStream(path)) });

// consumer — delivery already built THIS branch's own stream; msg._rows IS it:
async input(msg: Input<Port<{ _rows: Own<Readable> }>>) {
  for await (const chunk of msg._rows) {
    /* … */
  }
}

Use Own when each consumer needs its own — a readable stream fanned out to several readers (each must read from the start), a per-branch cursor or transaction, a buffer each branch fills independently. Anything with per-consumer state, or that can only be consumed once.

When the factory runs. It's preserved by reference across the clone (so it reaches every branch), then the framework calls it at delivery — as each message lands at a node's input, before your input() runs. So the primary wire, each fan-out clone, and a plain 1→1 wire all get a fresh instance, and your consumer just reads a plain value — no .make(), no call. Construction moves from clone-time (impossible for a live object) to delivery-time (trivial), and a branch that never receives the message never builds one.

Which do I reach for?

Ref<T>Own<T>
mental modela shared reference / singletona factory (new per branch)
each branch getsthe same instanceits own fresh instance
stateshared (aliased)isolated
who builds ityou, oncethe framework, per delivery
reach forclient, pool, emitter, resolverstream, cursor, per-branch buffer
don't use fora single-consumer stream (contended)something meant to be shared

Both read naturally at the consumer: msg._db (Ref) is the client; msg._rows (Own) is this branch's own stream.

The port is the contract (nominal typing)

Ref<T> and Own<T> are nominal: a bare T — or a bare () => T — does not satisfy a port declared Ref<T>/Own<T>, so send won't compile unless you use the constructor. That's on purpose: a plain live object would type-check and then break on the clone, so the type forces the wrapper and turns a runtime crash into an author-time error. The runtime marker is a non-enumerable symbol on the value — invisible to JSON and Object.keys, and carried on the value itself, so a Ref survives any number of hops. Input<Port<…>> rewrites each Own<T> field to the produced T, because delivery has already materialized it by the time input() runs.

The wire check compares the produced type T, so an Own<Readable> only feeds a reader that expects Own<Readable>. If two look-alikes must not interchange, brand the produced T itself (type-only — see below).

Naming: public data vs _ handles

Split what a node contributes into two kinds of key:

  • Public data — plain, serializable, useful to a flow author. Name it for what it is, namespaced by the producing node (httpIn.request, fileRead.path). A downstream change/switch/debug node can read it.
  • Internal handles — a live object or correlation handle a flow author should not touch. Prefix it with a leading underscore (_httpIn, _jobRunner). The underscore is the signal: "framework plumbing, hands off." A Ref/Own almost always lives on a _ key.
typescript
this.send("out", {
  httpIn: { request }, // public — the author reads/branches on this
  _httpIn: Ref({ res }), // internal — the live response http-out needs; hands off
});

Branding a correlation handle

When two nodes are a pair — a request and its response, a tool call and its return — the handle that links them should carry a nominal brand, so only the intended producer can feed the intended consumer and the wire check reds a mis-wire:

typescript
type ToolCall = { __brand: "@pkg/tool-call"; callId: string };
// producer:  _tool: { __brand: "@pkg/tool-call", callId }
// consumer:  input requires  _tool: ToolCall  → a shape-alike without the brand reds

__brand is a plain string literal (stable across bundles). It's there for the compile-time check and doubles as a debug marker — you can see who owns a handle at a glance. (A Ref-carried resolver is itself unforgeable, so pairing a resolver node to a non-source reds too.)

The durable alternative: store-and-key

Ref/Own put the live thing (or a factory for it) directly on the wire — which means they are in-process and ephemeral: they work only while the same process is alive and the object is still valid. When the correlation must survive a Node-RED restart (a deferred, persisted, or cross-process handoff), you can't ride a live reference. Fall back to the older pattern: keep the object in a module-scoped store and ride only a serializable id on a branded handle; a paired consumer takes it back by id.

typescript
// producer:  _src: { __brand: "@pkg/src", id }   (id from a module store)
// consumer:  const stream = await store.acquire(msg._src.id)

The id clones natively (it's a string) and the store owns cleanup. It's more machinery than Ref/Own; reach for it only when the wire value must stay a plain, restart-survivable id. For a purely in-process handle, Ref/Own is simpler, and it's what the built-in nodes use (http's _httpIn and jobs' _jobRunner both ride a Ref).

Conventions, not rules

None of this is enforced by nrg beyond the nominal Ref/Own typing — the _ naming and branding are how the built-in nodes are written so live objects survive fan-outs and a reader can tell data from plumbing. Follow the parts that help; a plain serializable port needs none of it.

Released under the MIT License.