@eden_labs/tree
v0.5.0
Published
Event manager for the Eden ecosystem
Downloads
19
Maintainers
Readme
@eden_labs/tree
Event manager for the Eden ecosystem. Fast, reliable event bus with at-least-once delivery guarantees — works on the same machine, across a local network, or across the internet through NAT via P2P hole punching and relay fallback.
How it works
Eden A Eden B
| |
| emit("eden:user:created") |
|──────── UDP ───────────────>|
| | on("eden:user:created", handler)
|<──────── ACK ───────────────|Events are sent as UDP packets. The emitter retries automatically if no ACK arrives within timeoutMs. The receiver deduplicates repeated deliveries — your handler is never called twice for the same event.
Quick start
Same machine / same network (UdpTransport, default)
import { Eden } from "@eden_labs/tree";
const a = new Eden({
listenPort: 5000,
remote: { host: "127.0.0.1", port: 5001 },
});
const b = new Eden({
listenPort: 5001,
remote: { host: "127.0.0.1", port: 5000 },
});
b.on("eden:user:created", (envelope) => {
console.log(envelope.payload); // { id: "123" }
});
b.on("eden:chat:message", (envelope) => {
console.log(envelope.payload);
}, { room: "sala-1" });
a.emit("eden:user:created", { id: "123" });
a.emit("eden:chat:message", { text: "hi" }, { room: "sala-1" });
a.stop();
b.stop();Multiple peers on a single socket (MultiUdpTransport)
When one process needs to communicate with many peers simultaneously, use MultiUdpTransport. It uses a single UDP socket regardless of how many peers are registered — saving file descriptors and event loop handles.
import { MultiUdpTransport } from "@eden_labs/tree";
const hub = new MultiUdpTransport();
// Register peers dynamically
hub.addPeer({ host: "192.168.1.10", port: 5000 });
hub.addPeer({ host: "192.168.1.11", port: 5000 });
hub.addPeer({ host: "192.168.1.12", port: 5000 });
// send() fans out to all registered peers
hub.send(Buffer.from("hello everyone"));
// bind() listens for messages from any peer on a single port
hub.bind(6000, (msg) => {
console.log("received:", msg.toString());
});
// Remove a peer when it disconnects
hub.removePeer({ host: "192.168.1.12", port: 5000 });
hub.close();Benchmark: overhead vs N individual UdpTransport instances is negligible (<2%), while using only 1 file descriptor regardless of N.
Across the internet (P2PTransport — NAT traversal)
For peers behind NAT (home networks, mobile, cloud VMs without public IP), use P2PTransport. It automatically tries:
- STUN — discovers your public IP/port
- UDP hole punching — opens a direct path through NAT (~85% of cases)
- WebSocket relay — fallback when hole punching fails (~15% of symmetric NAT)
You need a signaling server with a public IP — a small WebSocket server peers use to exchange endpoints before connecting. See Signaling server below.
import { Eden, P2PTransport } from "@eden_labs/tree";
const transport = new P2PTransport("peer-alice", "ws://your-signal-server:8080", {
stunServers: [
{ host: "stun.l.google.com", port: 19302 },
{ host: "stun.cloudflare.com", port: 3478 },
],
punchTimeoutMs: 3000,
signalingTimeoutMs: 5000,
});
const alice = new Eden({
listenPort: 0,
remote: { host: "0.0.0.0", port: 0 },
transport: () => transport,
});
// Connect to the other peer (both sides call connect simultaneously)
await transport.connect("peer-bob");
alice.on("eden:chat:message", (envelope) => {
console.log(envelope.payload);
});
alice.emit("eden:chat:message", { text: "hello over the internet!" });API
new Eden(options)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| listenPort | number | required | Local UDP port to listen on (0 = OS-assigned, for P2P) |
| remote.host | string | required | Remote host to send events to |
| remote.port | number | required | Remote port to send events to |
| timeoutMs | number | 5000 | Time (ms) before retrying an unacknowledged event |
| retryIntervalMs | number | 1000 | How often to check for expired events |
| transport | (target) => EdenTransport | UdpTransport | Custom transport factory |
eden.on(type, handler, options?)
Registers a listener for an event type. Returns an unsubscribe function.
const unsubscribe = eden.on("eden:user:created", (envelope) => {
console.log(envelope.type, envelope.payload);
});
// stop listening
unsubscribe();Options:
room?: string— only receive events sent to this room
eden.emit(type, payload, options?)
Emits an event to the remote instance.
eden.emit("eden:user:created", { id: "1" });
eden.emit("eden:chat:message", { text: "hi" }, { room: "sala-1" });Options:
room?: string— send to a specific room
eden.stop()
Closes all sockets and stops the retry interval. Must be called when the instance is no longer needed.
process.on("SIGTERM", () => {
eden.stop();
process.exit(0);
});MultiUdpTransport
new MultiUdpTransport()
No constructor arguments. Creates a single UDP socket internally.
| Method | Description |
|--------|-------------|
| addPeer(endpoint) | Register a peer endpoint { host, port } |
| removePeer(endpoint) | Unregister a peer endpoint |
| getPeerCount() | Returns the number of currently registered peers |
| send(msg) | Sends msg to all registered peers |
| bind(port, onMessage) | Listens on port, calls onMessage for any received message |
| close() | Closes the socket and clears all peers. Idempotent. |
P2PTransport
new P2PTransport(peerId, signalingUrl, options?)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| peerId | string | required | Unique ID for this peer |
| signalingUrl | string | required | WebSocket URL of your signaling server |
| stunServers | { host, port }[] | Google + Cloudflare STUN | STUN servers to discover public IP. Pass [] to skip STUN (same network) |
| stunTimeoutMs | number | 3000 | Timeout per STUN attempt |
| punchTimeoutMs | number | 3000 | Timeout for hole punching. Pass 0 to skip punching and go straight to relay |
| signalingTimeoutMs | number | 5000 | Timeout for signaling server responses |
transport.connect(targetPeerId)
Executes the full connection sequence (STUN → register → hole punch → relay fallback). Both peers must call connect pointing at each other simultaneously.
// Both peers call this at roughly the same time
await Promise.all([
transportAlice.connect("peer-bob"),
transportBob.connect("peer-alice"),
]);Signaling server
The signaling server is a small WebSocket server with a public IP that plays two roles:
Role 1 — Registry: peers exchange their public {host, port} before attempting hole punching. The server only stores endpoint metadata — it never sees your event payloads.
Role 2 — Relay: when hole punching fails (~15% of symmetric NAT cases), the server forwards encrypted UDP frames between peers over WebSocket. Both roles share the same URL and the same server process.
Protocol
// Role 1 — Registry (hole punching setup)
Client → Server: { type: "register", peerId, endpoint: { host, port } }
Server → Client: { type: "registered" }
Client → Server: { type: "request_connect", myId, targetId }
Server → Client: { type: "peer_endpoint", endpoint: { host, port } }
or: { type: "error", reason: "peer_not_found" }
// Role 2 — Relay (symmetric NAT fallback)
Client → Server: { type: "identify", peerId }
Client → Server: { type: "relay", fromPeerId, targetPeerId, payload: base64 }
Server → Target: { type: "data", from: fromPeerId, payload: base64 }P2PTransport first tries hole punching via the registry; if that fails, it falls back to relay — both use the same signalingUrl you passed to the constructor.
Minimal Node.js implementation
import { WebSocketServer, WebSocket } from "ws";
const server = new WebSocketServer({ port: 8080 });
interface PeerEntry {
endpoint: { host: string; port: number };
ws: WebSocket;
}
const peers = new Map<string, PeerEntry>();
server.on("connection", (ws) => {
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
// Role 1 — register public endpoint for hole punching
if (msg.type === "register") {
peers.set(msg.peerId, { endpoint: msg.endpoint, ws });
ws.send(JSON.stringify({ type: "registered" }));
}
// Role 1 — return remote peer's endpoint so both sides can punch
if (msg.type === "request_connect") {
const peer = peers.get(msg.targetId);
if (peer) {
ws.send(JSON.stringify({ type: "peer_endpoint", endpoint: peer.endpoint }));
} else {
ws.send(JSON.stringify({ type: "error", reason: "peer_not_found" }));
}
}
// Role 2 — re-associate WebSocket with peerId after relay reconnect
if (msg.type === "identify") {
const existing = peers.get(msg.peerId);
if (existing) peers.set(msg.peerId, { ...existing, ws });
}
// Role 2 — forward relay frame to target peer
if (msg.type === "relay") {
const target = peers.get(msg.targetPeerId);
if (target?.ws.readyState === WebSocket.OPEN) {
target.ws.send(
JSON.stringify({ type: "data", from: msg.fromPeerId, payload: msg.payload })
);
}
}
});
ws.on("close", () => {
// clean up disconnected peers
for (const [id, entry] of peers) {
if (entry.ws === ws) peers.delete(id);
}
});
});
console.log("Signaling server listening on ws://0.0.0.0:8080");Deployment notes
- The server must have a public IP — it cannot itself be behind NAT
- It holds state in memory; for multi-instance deployments you need a shared store (Redis, etc.)
- For production, add auth to prevent arbitrary peers from relaying through your server
Event types
Must follow the format {namespace}:{domain}:{action}:
eden:user:created ✓
eden:order:updated ✓
eden:chat:message ✓
user:created ✗ (missing namespace)
created ✗ (only one part)Throws EdenInvalidEventTypeError if the format is invalid.
Envelope
Every event is wrapped in an envelope automatically:
{
id: string; // UUID v4 — used for deduplication
type: string; // "eden:user:created"
payload: unknown; // your event data
timestamp: number; // Unix ms
version: string; // protocol version
room?: string; // present if emitted to a room
}Delivery guarantees
| What | How |
|------|-----|
| At-least-once | Emitter retries until ACK received |
| No duplicates | Receiver deduplicates by envelope id |
| Effectively exactly-once | Both combined |
Rooms
- No room → delivered to all listeners of that event type (broadcast)
- With room → delivered only to listeners subscribed to that room
// only receives events emitted to "sala-1"
eden.on("eden:chat:message", handler, { room: "sala-1" });
// receives all "eden:chat:message" events regardless of room
eden.on("eden:chat:message", handler);Errors
All errors extend EdenError:
| Error | When |
|-------|------|
| EdenInvalidEventTypeError | Event type doesn't follow {ns}:{domain}:{action} format |
| EdenInvalidEnvelopeError | Received message is not valid JSON or missing required fields |
| EdenStunTimeoutError | No STUN server responded within stunTimeoutMs |
| EdenSignalingError | Signaling server returned an error or timed out |
import { EdenError, EdenSignalingError, EdenStunTimeoutError } from "@eden_labs/tree";
try {
await transport.connect("peer-bob");
} catch (err) {
if (err instanceof EdenStunTimeoutError) {
console.error("Could not discover public IP — check STUN server");
}
if (err instanceof EdenSignalingError) {
console.error("Peer not found or signaling server unreachable");
}
}Performance (loopback 127.0.0.1)
Measured with sequential ping-pong RTT (1000 samples):
| Transport | p50 | p95 | p99 | Throughput | |-----------|-----|-----|-----|------------| | UdpTransport (baseline) | 0.051 ms | 0.077 ms | 0.116 ms | 15,578 msg/s | | P2PTransport (hole punch) | 0.053 ms | 0.074 ms | 0.141 ms | 15,230 msg/s | | P2PTransport (relay) | 0.104 ms | 0.158 ms | 0.222 ms | 8,176 msg/s |
P2PTransport adds ~2.6% overhead after connection is established (hole punch path). The relay path adds ~103% because it goes through a WebSocket/TCP intermediary — still fast enough for most use cases, and only used as a fallback.
MultiUdpTransport — fanout tail latency (time until last of N peers receives, loopback):
| N peers | MultiUdpTransport (1 socket) p50 | N × UdpTransport (N sockets) p50 | overhead | |---------|----------------------------------|-----------------------------------|---------| | 10 | 0.104 ms | 0.104 ms | ~0% | | 50 | 0.474 ms | 0.465 ms | ~2% | | 200 | 1.881 ms | 1.895 ms | ~0% |
Latency scales with N peers due to N sequential socket.send() calls, not due to the abstraction. The single-socket overhead vs N individual transports is negligible.
Real-world latencies will be higher due to actual network RTT.
Run the benchmark yourself:
npm run benchDevelopment
npm test # run all unit tests
npm run test:watch # watch mode
npm run test:integration # real network tests (STUN against stun.l.google.com)
npm run build # compile to dist/
npm run bench # transport benchmarkAll code follows strict TDD — no production code without a failing test first.
