freeswitch-esl-node
v1.0.0
Published
Dependency-free FreeSWITCH Event Socket (ESL) client for Node.js. TypeScript types, correct Content-Length framing, bgapi, events, auto-reconnect, ESM and CommonJS.
Maintainers
Readme
freeswitch-esl-node
A small, dependency-free FreeSWITCH Event Socket client for Node.js, inbound mode.
ESL is a header block plus an optional body over TCP, so this package hand-rolls it against node:net. Nothing else is pulled into your tree.
npm install freeswitch-esl-node- Zero runtime dependencies
- TypeScript types included, written in TypeScript
- Ships both ESM and CommonJS
- Waits for the full declared
Content-Lengthbefore handing you a body bgapiresolves with the job result, correlated byJob-UUID- Events kept out of command replies, and command replies kept out of events
- Optional auto-reconnect that re-authenticates and re-subscribes
- Node 18 or newer
Quick start
import { EslClient } from "freeswitch-esl-node";
const esl = new EslClient({
host: "127.0.0.1",
port: 8021,
password: "ClueCon",
});
await esl.connect();
console.log(await esl.api("status"));
console.log(await esl.api("sofia status profile internal"));
esl.close();CommonJS works the same way:
const { EslClient } = require("freeswitch-esl-node");FreeSWITCH side setup
The event socket is on by default, listening on 127.0.0.1:8021 with the password ClueCon. Change it in autoload_configs/event_socket.conf.xml:
<configuration name="event_socket.conf" description="Socket Client">
<settings>
<param name="listen-ip" value="127.0.0.1"/>
<param name="listen-port" value="8021"/>
<param name="password" value="something-that-is-not-ClueCon"/>
</settings>
</configuration>Then fs_cli -x "reload mod_event_socket". Keep listen-ip on localhost and reach it over a tunnel or a private network. Anyone who can open that port owns the switch.
Why the framing matters
The obvious way to write an ESL client is to emit a frame as soon as you see the blank line ending the headers. That works right up until you run show channels on a busy switch, the body spans several TCP segments, and you get a confidently truncated answer with no error at all.
Twelve channels looks exactly like twelve channels whether or not there were really ninety.
This client reads Content-Length and waits for the whole declared body, counted in bytes rather than characters so multibyte caller IDs do not shift the boundary. There is a test that feeds a 400-row response through in 500-byte chunks.
Events
const esl = new EslClient({
host,
password,
subscribe: ["CHANNEL_CREATE", "CHANNEL_ANSWER", "CHANNEL_HANGUP_COMPLETE"],
});
esl.on("CHANNEL_ANSWER", (e) => console.log("answered", e["Unique-ID"], e["Caller-Destination-Number"]));
esl.on("event", (e) => metrics.count(e["Event-Name"]));
await esl.connect();Use subscribe: "ALL" for the firehose, or call esl.subscribe([...]) later. Calling it again adds to the subscription rather than replacing it, matching FreeSWITCH's own behaviour.
Header values are percent-decoded for you. A malformed sequence is left as-is instead of throwing, because a literal % in a caller name should not take down your parser.
filter() maps to the ESL filter command if you want the switch to do the narrowing:
await esl.filter("Event-Name", "CHANNEL_ANSWER");Background jobs
const uuid = await esl.bgapi("originate user/1001 &echo");bgapi returns a promise that settles when the matching BACKGROUND_JOB event arrives, correlated by a Job-UUID this client generates. It subscribes to BACKGROUND_JOB the first time you call it. That event is consumed rather than passed on, so it will not show up in your own event handlers.
Use it for anything slow. api() blocks the socket until the switch answers, which on a long originate means nothing else gets through.
Staying connected
const esl = new EslClient({
host,
password,
subscribe: ["CHANNEL_HANGUP_COMPLETE"],
reconnect: { delayMs: 1000, maxDelayMs: 30_000, retries: Infinity },
});
esl.on("reconnecting", (attempt, delay) => console.warn(`retry ${attempt} in ${delay}ms`));
esl.on("reconnected", () => console.log("back, and re-subscribed"));
esl.on("error", (err) => console.error(err.message));Reconnect is off unless you ask for it. On success the client re-authenticates and replays your subscribe option, so you do not silently end up connected but deaf.
Any command still in flight when the connection drops rejects immediately rather than hanging until its timeout.
API
new EslClient(options)
| Option | Default | Meaning |
| --- | --- | --- |
| host | required | FreeSWITCH host |
| port | 8021 | Event socket port |
| password | required | Event socket password |
| timeoutMs | 10000 | Connect timeout, and default command timeout |
| subscribe | none | Event names to subscribe to on connect, or "ALL" |
| reconnect | off | true, or { retries, delayMs, maxDelayMs } |
Methods
connect()opens the socket and authenticates.api(command, timeoutMs?)runs an API command and resolves with its output. A-ERRreply rejects.bgapi(command, timeoutMs?)runs it in the background and resolves with the job result.subscribe(events)subscribes to more events.filter(header, value)adds a server-side event filter.sendRecv(command, headers?, timeoutMs?)sends a raw ESL command and resolves with the reply frame.close()disconnects and cancels any reconnect.
Events
connect, close, error, reconnecting, reconnected, disconnectNotice, frame (every decoded frame), event (FreeSWITCH events only), plus one channel per event name.
Helpers
parseHeaders(raw)parses a header block, percent-decoding values.parseEventPlain(body)parses an event, including its own nested body.serializeCommand(command, headers?)builds a command, throwing on line breaks.decodeValue(raw)percent-decodes one value, tolerating malformed input.EslErrorthe error class everything here throws.
Scope
Inbound mode only, which is what you want for monitoring and control. Outbound mode, where FreeSWITCH connects to you from the dialplan, is not implemented. Open an issue if you need it.
Commands are passed through as written. Anything reaching api() runs with full event socket privileges, so if you expose it to users, check the command against an allow list first.
Related
- pbx-mcp, a Model Context Protocol server that gives AI assistants a read-only window into Asterisk and FreeSWITCH. This client came out of building it, and the truncation bug above is one we actually shipped and then fixed.
- asterisk-ami-node, the same idea for the Asterisk Manager Interface.
- ICTCore, the open source telephony framework behind our products.
About
Built and maintained by Tahir Almas at ICT Innovations, who have been shipping open source telephony since 2005.
Issues and pull requests are welcome. Bug reports with the actual error string are the most useful thing you can send.
License
MIT
