edgeport
v1.0.6
Published
TCP Library for Cloudflare Workers
Downloads
1,328
Readme
edgeport
🛜 TCP Library for Cloudflare Workers
edgeport gives Cloudflare Workers native clients for various TCP protocols, built directly on cloudflare:sockets.
Node libraries assume Node's net/tls and Node crypto, none of which exist in workerd;
edgeport is written for the Workers runtime from the ground up.
Table of Contents
- Why edgeport
- Features
- Install
- Getting Started
- SSH
- SFTP
- SMTP
- IMAP
- POP3
- WebSocket
- NATS
- MQTT
- STOMP
- FTP
- LDAP / LDAPS
- Syslog
- SMPP
- SIP (RCS)
- DNS
- XMPP
- IRC
- Redis
- Utilities
- Real-World Recipes
- Error Handling
- Limitations
- API Reference
- Contributing
- License
Why edgeport
Workers can open raw TCP sockets, but every existing SSH/mail client depends on Node APIs the runtime does not provide. edgeport is:
- Workers-Native. One small core is the only code that imports
cloudflare:sockets; every protocol builds on it. Nothing depends on Node. - TypeScript-first. The types are the contract and the documentation.
- Tree-Shakeable. Import only the protocol you use (
edgeport/ssh,edgeport/smtp, ...). - Tested against Real Servers. Every protocol is verified under
workerdagainst Dockerized servers (OpenSSH, Dropbear, GreenMail, NATS with JetStream, Mosquitto, ActiveMQ, OpenLDAP, an FTP server, an SMPP SMSC simulator, a Kamailio SIP server, a CoreDNS resolver, an ejabberd XMPP server, an Ergo IRC server, a Redis server, and a WebSocket echo server) - not mocks.
Features
- SSH transport, auth, and connection layers:
exec, interactiveshell, subsystems. - SFTP v3 over SSH: read/write/list/stat/rename/remove, plus streaming.
- SMTP send with STARTTLS / implicit TLS and AUTH PLAIN/LOGIN.
- IMAP and POP3 read access with STARTTLS / implicit TLS.
- WebSocket client via the platform API (
for awaitmessage iteration). - NATS pub/sub, request-reply, and queue groups; token / user-pass / nkey / JWT (creds) auth.
- MQTT v3.1.1 (QoS 0/1/2, keep-alive, wildcards) over raw TCP or WebSocket.
- STOMP 1.2 messaging (send / subscribe / ack) for ActiveMQ, RabbitMQ, and friends.
- FTP (plaintext, passive mode): upload, download, list, and directory ops.
- LDAP / LDAPS simple bind + search with RFC 4515 filters (BER codec, StartTLS).
- Syslog RFC 5424 over TCP/TLS with octet-counting or LF framing.
- SMPP v3.4 client (ESME): bind,
submit_sm, and inbounddeliver_sm/ delivery receipts. - SIP / RCS user agent over TCP/TLS: register,
MESSAGE,OPTIONS, presence, and MSRP chat. - DNS over TCP (RFC 1035):
resolve, typed record helpers, reverse lookups, and raw queries. - XMPP (RFC 6120/6121): presence, messages, roster, and pubsub through a concept API.
- IRC (RFC 1459/2812 + IRCv3): channels, messages, events, SASL, and raw commands.
- Redis (RESP2/RESP3): typed commands, pipelines, transactions, Lua scripting, and Pub/Sub.
- Email-to-SMS carrier-gateway addressing layered on SMTP (
sendSms). - Utilities (
edgeport/util): hex/base64 codecs, random ids, retry-with-backoff, email-address parsing, and promise deadlines. - One uniform error vocabulary:
AuthError,ConnectionError,ProtocolError,TimeoutError.
SSH algorithm support
| Category | Supported |
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
| Key Exchange | curve25519-sha256, ecdh-sha2-nistp256 |
| Host / User Keys | Ed25519, ECDSA-P256, RSA-SHA2-256/512 |
| Ciphers | [email protected], [email protected], [email protected], aes256-ctr, aes128-ctr |
| MAC | hmac-sha2-256, hmac-sha2-512 (AEAD Ciphers carry their own) |
| Auth | publickey, password, keyboard-interactive |
ChaCha20-Poly1305 is assembled from @noble/ciphers
because Workers WebCrypto does not provide it.
Install
bun add edgeport
# or: npm install edgeportedgeport targets the Workers runtime. Use it inside a Worker, not in plain Node.
Getting Started
import { exec } from 'edgeport/ssh';
export default {
async fetch(): Promise<Response> {
const { stdoutText, code } = await exec({
hostname: 'example.com',
username: 'deploy',
password: env.SSH_PASSWORD,
command: 'uptime'
});
return new Response(`exit ${code}\n${stdoutText}`);
}
};SSH
One-shot Exec
import { exec } from 'edgeport/ssh';
const { stdout, stdoutText, code } = await exec({
hostname: 'host',
username: 'user',
privateKey: { pem: env.SSH_KEY }, // PKCS8 PEM, or pass a CryptoKey
command: 'ls -la /var/log'
});
// stdoutText / stderrText are the decoded strings; stdout / stderr keep the raw bytesA reusable Session
import { connect } from 'edgeport/ssh';
await using ssh = await connect({ hostname: 'host', username: 'user', password: env.PW });
const a = await ssh.exec('hostname');
const b = await ssh.exec('date');
// the session closes automatically at the end of the `await using` scopeInteractive Shell (streaming)
import { connect } from 'edgeport/ssh';
import { toUtf8 } from 'edgeport/util';
await using ssh = await connect({ hostname: 'host', username: 'user', password: env.PW });
await using shell = await ssh.shell();
await shell.write('echo hi\n');
const reader = shell.stdout.getReader();
const { value } = await reader.read();
console.log(toUtf8(value!));Keyboard-Interactive and Host-Key Pinning
await using ssh = await connect({
hostname: 'host',
username: 'user',
onKeyboardInteractive: async (prompts) => prompts.map(() => env.OTP),
hostKey: {
verify: (type, key) => type === 'ssh-ed25519' /* && key matches your pinned host key */
}
});Executing with Sudo
sudo does not read its password from SSH auth - it prompts over the channel. The sudo
helpers run sudo -S -p '' (read the password from stdin, silence sudo's own prompt) and
feed it the secret for you. The sudoers policy must allow password sudo without a tty
(requiretty off, the default on most modern distros).
One-shot over a fresh connection - sudoPassword defaults to password, so the SSH login
password is reused as the sudo password (the common case):
import { sudoExec } from 'edgeport/ssh';
const { stdoutText, code } = await sudoExec({
hostname: 'host',
username: 'user',
password: env.PW,
command: 'systemctl restart myapp'
});
console.log(code, stdoutText);Reusing an already-open session (pass the sudo password explicitly):
import { connect, sudo } from 'edgeport/ssh';
await using ssh = await connect({ hostname: 'host', username: 'user', password: env.PW });
const result = await sudo(ssh, 'whoami', { password: env.PW });
console.log(result.lastLine()); // "root"Forcing a Cipher
await using ssh = await connect({
hostname: 'host',
username: 'user',
password: env.PW,
algorithms: { cipher: ['[email protected]'] }
});Encrypted (Passphrase-Protected) Keys
Encrypted PKCS#8 (BEGIN ENCRYPTED PRIVATE KEY) and OpenSSH-format keys (BEGIN OPENSSH
PRIVATE KEY, the ssh-keygen default), encrypted or not, are accepted - pass the
passphrase:
await using ssh = await connect({
hostname: 'host',
username: 'user',
privateKey: { pem: env.SSH_KEY, passphrase: env.SSH_KEY_PASSPHRASE }
});Key Re-Exchange (Rekey)
Long-lived sessions and large transfers rekey automatically (default every ~1 GiB, per RFC 4253 §9); server-initiated rekeys are handled transparently. Tune or force it:
await using ssh = await connect({
hostname: 'host',
username: 'user',
password: env.PW,
rekeyThresholdBytes: 256 * 1024 * 1024 // auto-rekey every 256 MiB (0 disables)
});
await ssh.rekey(); // or force one nowPort Forwarding (Tunneling)
forwardOut opens a direct-tcpip channel: the SSH server connects to a target on your
behalf and pipes the bytes back, so a Worker can reach a service that isn't internet-exposed
through an SSH bastion (the -L reach-through). It returns a duplex channel - stdout is
inbound bytes, write() sends outbound.
import { connect } from 'edgeport/ssh';
await using ssh = await connect({
hostname: 'bastion',
username: 'u',
privateKey: { pem: env.SSH_KEY }
});
// reach an internal-only Postgres that sits behind the bastion
await using tunnel = await ssh.forwardOut('10.0.0.5', 5432);
await tunnel.write(startupPacket);
for await (const chunk of tunnel.stdout) {
// handle each inbound chunk from the tunneled service here
}Workers cannot accept inbound connections, so the listening half of -L/-D (a local SOCKS
listener) and remote forwarding (-R) are out of scope - the server-side reach-through is the
valuable part and is fully supported. The server must permit TCP forwarding
(AllowTcpForwarding).
SSH Command Helpers
On top of exec/execStream, a session carries ergonomic shell helpers. Each one
single-quotes the paths and arguments it interpolates, so a space or $ in a path cannot
break the command; the destructive rm also refuses obviously dangerous targets (/, ~,
., ..).
These helpers assume a POSIX shell, so they work against Linux, macOS, and other Unix
remotes - stat and spawnDetached handle the GNU/BSD differences (GNU stat -c vs BSD
stat -f; nohup rather than Linux-only setsid). For a Windows (cmd.exe/PowerShell) or
other non-POSIX remote, drop down to run/exec/execStream with native commands.
import { connect } from 'edgeport/ssh';
await using ssh = await connect({ hostname: 'host', username: 'user', password: env.PW });
const host = await ssh.run('hostname'); // decoded, trimmed stdout; throws on a nonzero exit
if (await ssh.test('command -v docker')) {
// docker is installed
}
if (await ssh.exists('/etc/hosts')) {
// the file is there
}// files and directories
await ssh.mkdirp('/srv/app/releases', { mode: 0o755 });
await ssh.writeTextFile('/srv/app/.env', 'PORT=8080\n');
const envFile = await ssh.readTextFile('/srv/app/.env');
await ssh.chmod('/srv/app/run.sh', 0o755);
const st = await ssh.stat('/srv/app'); // { size, mode, mtime, isDirectory, isSymlink }
await ssh.rm('/srv/app/tmp', { recursive: true, force: true });
// system introspection and background work
const usage = await ssh.df('/srv'); // [{ filesystem, sizeKb, usedKb, availKb, usePercent, mountedOn }]
const git = await ssh.which('git'); // '/usr/bin/git' | null
await ssh.spawnDetached('/srv/app/worker', { stdout: '/var/log/worker.log' });For one-call workflows there are connect-run-close one-shots:
import { run, test, exists } from 'edgeport/ssh';
const out = await run({ hostname: 'host', username: 'user', password: env.PW, command: 'uptime' });
const ok = await exists({
hostname: 'host',
username: 'user',
password: env.PW,
path: '/etc/hosts'
});SFTP
import { connect, getFile, putFile } from 'edgeport/sftp';
// one-shots
const bytes = await getFile({ hostname: 'h', username: 'u', password: p, path: '/etc/hostname' });
await putFile({ hostname: 'h', username: 'u', password: p, path: '/tmp/x', data: bytes });
// a session
await using sftp = await connect({ hostname: 'h', username: 'u', password: p });
await sftp.mkdir('/tmp/reports');
await sftp.writeFile('/tmp/reports/today.csv', 'a,b,c\n');
for (const entry of await sftp.list('/tmp/reports')) {
console.log(entry.filename, entry.attrs.size);
}
// stream a large download
const stream = sftp.createReadStream('/var/log/big.log');Reusing an Existing Session
Reuse an existing SSH session instead of opening a second connection:
import { connect as sshConnect } from 'edgeport/ssh';
import { connect as sftpConnect } from 'edgeport/sftp';
await using ssh = await sshConnect({ hostname: 'h', username: 'u', password: p });
await using sftp = await sftpConnect({ session: ssh });Large File Transfers
For large files, use the streaming API to avoid buffering the entire file in memory:
await using sftp = await connect({ hostname: 'h', username: 'u', password: p });
const readStream = sftp.createReadStream('/var/log/big.log');
const writeStream = sftp.createWriteStream('/tmp/bigfile');SFTP Convenience Helpers
Higher-level helpers built on the same request/response framing:
import { connect } from 'edgeport/sftp';
await using sftp = await connect({ hostname: 'h', username: 'u', password: p });
// presence check (a "no such file" status resolves false; other errors propagate)
if (!(await sftp.exists('/srv/app'))) {
await sftp.ensureDir('/srv/app/releases/2026'); // recursive mkdir, one level per segment
}
// text and JSON round-trips (UTF-8)
await sftp.writeText('/srv/app/note.txt', 'deployed\n');
const note = await sftp.readText('/srv/app/note.txt');
await sftp.writeJson('/srv/app/config.json', { port: 8080 }, { space: 2 });
const config = await sftp.readJson<{ port: number }>('/srv/app/config.json');
await sftp.writeFile('/srv/app/data.txt', 'writeFile also accepts a string (UTF-8 encoded)');
await sftp.chmod('/srv/app/run.sh', 0o755);
// removal: one empty dir, a batch of files, or a whole tree
await sftp.rmdir('/srv/app/empty');
await sftp.removeMany(['/tmp/a.log', '/tmp/b.log'], { ignoreMissing: true });
await sftp.removeAll('/srv/app/releases/old'); // recursive, client-side, non-atomicremoveAll walks the tree from the client (one round trip per entry) and refuses an empty
path or /; ensureDir tolerates segments that already exist and verifies the leaf is a
directory.
SMTP
Port 25 is blocked on Workers, so edgeport sends over submission (587, STARTTLS) or implicit TLS (465).
import { send } from 'edgeport/smtp';
await send({
hostname: 'smtp.example.com',
port: 587, // STARTTLS (default)
auth: { username: '[email protected]', password: env.SMTP_PW },
from: '[email protected]',
to: ['[email protected]'],
subject: 'Deploy finished',
text: 'The 14:00 deploy completed successfully.'
});One-shot Connect + Send with HTML
import { connect } from 'edgeport/smtp';
await using smtp = await connect({
hostname: 'smtp.example.com',
port: 465,
tls: 'implicit',
auth: { username: 'u', password: env.SMTP_PW }
});
await smtp.send({ from: 'u@x', to: 'a@y', subject: 'Hi', html: '<h1>Hi</h1>', text: 'Hi' });Cloudflare Email Service
Using the Cloudflare Email Service, your workers can now send emails on-demand without the additional cost of a SMTP provider. For example:
import { send } from 'edgeport/smtp';
await send({
hostname: 'smtp.mx.cloudflare.net',
tls: 'implicit', // port defaults to 465 for implicit
auth: {
username: 'api_token', // literal string, per Cloudflare docs
password: env.CF_EMAIL_TOKEN, // API token w/ Email Sending: Edit
mechanism: 'PLAIN' // LOGIN also supported
},
from: '[email protected]', // domain must be onboarded to Email Sending
to: '[email protected]', // any valid email address rather than verified recipient
subject: 'Hello',
text: 'Sent from a Worker over edgeport SMTP.'
});Attachments and Plaintext Relays
Pass attachments to build a multipart/mixed message; pass tls: 'off' to talk to a
trusted internal relay or dev server with no TLS.
await send({
hostname: 'relay.internal',
tls: 'off', // plaintext (trusted network); 'starttls' (default) and 'implicit' also supported
from: 'reports@internal',
to: 'team@internal',
subject: 'Daily report',
text: 'Attached.',
attachments: [{ filename: 'report.csv', content: csvBytes, contentType: 'text/csv' }]
});Email-to-SMS Gateway
Many carriers accept email at a gateway that forwards it to the handset as a text message.
sendSms builds that gateway address from a phone number and a carrier - a known key from
CarrierGateways, or a raw gateway domain - then sends a short plaintext message over the
normal SMTP path.
import { sendSms, smsAddress } from 'edgeport/smtp';
// resolve the gateway address (number@gateway-domain), normalizing the number
smsAddress('+1 (555) 123-4567', 'att'); // '[email protected]'
await sendSms({
hostname: 'smtp.example.com',
auth: { username: '[email protected]', password: env.SMTP_PW },
from: '[email protected]',
to: { number: '5551234567', carrier: 'verizon' },
text: 'Your code is 123456'
});Coverage is US + Canadian carriers (att, tmobile, verizon, sprint, cricket,
rogers, bell, telus, ...); pass a raw gateway domain for anything not listed. Gateway
domains are best-effort and drift over time, so a raw-domain override is the escape hatch.
Use type: 'mms' for a carrier's picture-message gateway where it differs.
IMAP
import { connect, fetchRecent } from 'edgeport/imap';
const recent = await fetchRecent({
hostname: 'imap.example.com',
auth: { username: 'u', password: env.PW },
mailbox: 'INBOX',
count: 10
});
await using imap = await connect({
hostname: 'imap.example.com',
auth: { username: 'u', password: env.PW }
});
const { exists } = await imap.select('INBOX');
const uids = await imap.search({ unseen: true });
const messages = await imap.fetch(uids, { envelope: true, body: true });
for (const m of messages) console.log(m.text()); // decoded body; m.body keeps the raw bytesOne-shot Fetch Recent
Fetch the most recent N messages in one call (not streaming):
import { fetchRecent } from 'edgeport/imap';
const recent = await fetchRecent({
hostname: 'imap.example.com',
auth: { username: 'u', password: env.PW },
mailbox: 'INBOX',
count: 10
});POP3
import { retrieveAll, connect } from 'edgeport/pop3';
const all = await retrieveAll({
hostname: 'pop.example.com',
auth: { username: 'u', password: env.PW }
});
await using pop = await connect({
hostname: 'pop.example.com',
auth: { username: 'u', password: env.PW }
});
const { count } = await pop.stat();
const first = await pop.retrieve(1); // raw bytes
const firstText = await pop.retrieveText(1); // decoded string, no TextDecoder neededOne-shot Retrieve All
Retrieve all messages in one call (not streaming):
import { retrieveAll } from 'edgeport/pop3';
const all = await retrieveAll({
hostname: 'pop.example.com',
auth: { username: 'u', password: env.PW }
});WebSocket
The WebSocket client uses the platform WebSocket API; the runtime handles TLS, framing, and masking.
import { connect } from 'edgeport/ws';
const ws = await connect('wss://stream.example.com/feed', { protocols: ['v1'] });
ws.send(JSON.stringify({ subscribe: 'ticks' }));
// directly iterate messages with `for await`
for await (const msg of ws) {
if (msg.type === 'text') {
const event = JSON.parse(msg.data);
// handle the parsed event here
}
}
const { code, reason } = await ws.closed;JSON Helpers
import { connect } from 'edgeport/ws';
const ws = await connect('wss://stream.example.com/feed');
ws.sendJson({ subscribe: 'ticks' });
// json() and text() work on every frame (text OR binary) - no branching on type, no
// TextDecoder; a server that sends JSON over binary frames just works
for await (const msg of ws) {
const event = msg.json<{ price: number }>();
// handle the parsed event here
}NATS
import { connect } from 'edgeport/nats';
await using nc = await connect({ hostname: 'nats.example.com', token: env.NATS_TOKEN });
// pub/sub
const sub = nc.subscribe('orders.*', { queue: 'workers' });
await nc.publish('orders.created', JSON.stringify({ id: 42 }));
for await (const msg of sub) {
const order = msg.json();
// handle the order here
}
// request-reply
const reply = await nc.request('time.now', '', { timeoutMs: 1000 });NKey Authorization
nkey (ed25519) auth: pass nkeySeed (a SU... seed) instead of a token. For managed NATS
(Synadia NGS) or any JWT-secured deployment, pass the contents of a .creds file - the user
JWT and signing seed are extracted automatically:
import { connect } from 'edgeport/nats';
await using nc = await connect({ hostname: 'connect.ngs.global', creds: env.NATS_CREDS });Subscribe with a Queue Group
Distribute messages across multiple workers by subscribing with a queue group:
import { connect } from 'edgeport/nats';
await using nc = await connect({ hostname: 'nats.example.com', token: env.NATS_TOKEN });
const sub = nc.subscribe('orders.*', { queue: 'workers' });
// directly iterate messages with `for await`
for await (const msg of sub) {
const order = msg.json();
// one member of the 'workers' queue group receives each message
}JetStream
Durable, at-least-once streams via nc.jetstream(): ensure a stream, publish with a
PubAck, and pull with a durable consumer that survives reconnects (un-acked messages are
redelivered; acked ones are not).
await using nc = await connect({ hostname: 'nats.example.com', token: env.NATS_TOKEN });
const js = nc.jetstream();
await js.ensureStream('EVENTS', { subjects: ['events.>'] });
const ack = await js.publish('events.created', JSON.stringify({ id: 42 })); // { stream, seq }
const consumer = await js.pullSubscribe('EVENTS', 'worker-durable', { ackWaitMs: 30_000 });
for (const msg of await consumer.fetch(10, { expiresMs: 5000 })) {
// process msg.data here
await msg.ack(); // un-acked messages are redelivered after a reconnect
}JSON Helpers
publishJson/requestJson and the per-message json()/text() accessors remove the
encode/parse boilerplate:
import { connect } from 'edgeport/nats';
await using nc = await connect({ hostname: 'nats.example.com', token: env.NATS_TOKEN });
await nc.publishJson('readings', { temp: 21.5 });
for await (const { value } of nc.subscribeJson<{ temp: number }>('readings')) {
console.log(value.temp);
}
const reply = await nc.requestJson<{ sum: number }>('calc.add', [2, 3]);A subscribe responder replies with msg.respond(value); any received message exposes
msg.json<T>() and msg.text().
MQTT
Raw TCP (1883 / TLS 8883) or over WebSocket - same API.
import { connect, connectWebSocket } from 'edgeport/mqtt';
await using mqtt = await connect({
hostname: 'broker.example.com',
port: 8883,
tls: 'implicit',
clientId: 'edge-worker-1',
username: env.MQTT_USER,
password: env.MQTT_PASS
});
await mqtt.publish('sensors/edge/temp', '21.4', { qos: 1, retain: true });
const sub = mqtt.subscribe('sensors/+/temp', { qos: 1 });
for await (const m of sub) console.log(m.topic, m.text());
// or tunnel MQTT through a WebSocket broker endpoint
const overWs = await connectWebSocket('wss://broker.example.com:8884/mqtt', {
clientId: 'edge-ws'
});Subscribe with Wildcards
Subscribe to topics with + (single-level) and # (multi-level) wildcards:
import { connect } from 'edgeport/mqtt';
await using mqtt = await connect({ hostname: 'broker.example.com', clientId: 'edge' });
const sub = mqtt.subscribe('sensors/+/temp', { qos: 1 });
for await (const m of sub) {
console.log(m.topic, m.text());
}Last Will and Persistent Sessions
Set a will and the broker publishes it if the client drops without a clean disconnect -
the basis for presence / offline detection. Use cleanSession: false with a fixed
clientId so queued QoS-1 messages are drained on reconnect.
await using device = await connect({
hostname: 'broker.example.com',
clientId: 'device-42',
cleanSession: false, // persistent session: queued QoS>=1 messages survive a reconnect
will: { topic: 'devices/42/status', payload: 'offline', qos: 1, retain: true }
});
await device.publish('devices/42/status', 'online', { retain: true });
// ... later, an unexpected drop publishes the will; a clean shutdown does not:
await device.close({ graceful: false }); // abrupt -> broker fires the 'offline' willJSON Helpers
import { connect } from 'edgeport/mqtt';
await using mqtt = await connect({ hostname: 'broker.example.com', clientId: 'edge' });
await mqtt.publishJson('sensors/1', { temp: 21.5 }, { qos: 1 });
for await (const { topic, value } of mqtt.subscribeJson<{ temp: number }>('sensors/+', {
qos: 1
})) {
console.log(topic, value.temp);
}A plain subscribe message also exposes msg.json<T>() and msg.text().
STOMP
import { connect } from 'edgeport/stomp';
await using stomp = await connect({
hostname: 'mq.example.com',
login: env.MQ_USER,
passcode: env.MQ_PASS
});
await stomp.send('/queue/jobs', JSON.stringify({ task: 'resize' }));
const sub = stomp.subscribe('/queue/jobs', { ack: 'client' });
for await (const m of sub) {
// process the job (m.body) here
await m.ack?.();
}Heartbeats
Cloud messaging brokers often require heartbeats to keep the connection alive. edgeport supports STOMP heartbeats:
import { connect } from 'edgeport/stomp';
await using stomp = await connect({
hostname: 'mq.example.com',
login: env.MQ_USER,
passcode: env.MQ_PASS,
heartBeat: [10000, 10000] // [send, expect] in ms; negotiated down with the broker
});Transactions
Stage sends in a transaction; the broker releases them only on commit() and discards them
on abort().
const tx = await stomp.begin();
await tx.send('/queue/orders', JSON.stringify(order));
await tx.send('/queue/audit', JSON.stringify(entry));
if (ok)
await tx.commit(); // both messages delivered atomically
else await tx.abort(); // neither is ever deliveredJSON Helpers
import { connect } from 'edgeport/stomp';
await using stomp = await connect({
hostname: 'mq.example.com',
login: env.MQ_USER,
passcode: env.MQ_PASS
});
await stomp.sendJson('/queue/jobs', { job: 'reindex', n: 7 }); // sets content-type: application/json
const sub = stomp.subscribe('/queue/jobs');
for await (const msg of sub) {
const job = msg.json<{ job: string; n: number }>();
// handle the job here
}FTP
Plaintext FTP, passive mode (Workers cannot accept the inbound connections active mode needs).
import { connect, getFile, putFile } from 'edgeport/ftp';
await using ftp = await connect({
hostname: 'files.example.com',
username: 'u',
password: env.FTP_PW
});
await ftp.put('reports/today.csv', 'a,b,c\n');
for (const entry of await ftp.list('reports')) console.log(entry.name, entry.size);
const bytes = await ftp.get('reports/today.csv');ASCII Mode and Resume
get/put take an options object: type: 'ascii' issues TYPE A (line-ending conversion)
vs the default 'binary' (TYPE I); offset issues REST <n> to resume a download, and
append: true resumes an upload (APPE) - the practical recovery path after a dropped data
channel.
// resume an interrupted upload from where it stopped, and download just the tail
await ftp.put('big.bin', firstChunk);
await ftp.put('big.bin', restOfFile, { append: true });
const tail = await ftp.get('big.bin', { offset: firstChunk.length });
// transfer a text file in ASCII mode
await ftp.put('records.txt', data, { type: 'ascii' });Convenience Helpers
Higher-level helpers over the raw commands; paths resolve relative to the working directory unless absolute.
import { connect } from 'edgeport/ftp';
await using ftp = await connect({
hostname: 'files.example.com',
username: 'u',
password: env.FTP_PW
});
if (await ftp.exists('/etc/app/config.json')) {
const cfg = await ftp.getJson<{ name: string }>('/etc/app/config.json');
}
await ftp.ensureDir('/incoming/2026/reports'); // recursive mkdir, one MKD per segment
await ftp.putText('/incoming/note.txt', 'hello world\n');
const note = await ftp.getText('/incoming/note.txt');
const when = await ftp.mtime('/incoming/note.txt'); // MDTM, parsed as UTC
await ftp.removeAll('/incoming/2026'); // recursive, client-side, non-atomic; refuses '' and '/'LDAP / LDAPS
import { connect } from 'edgeport/ldap';
// or: import { connect } from 'edgeport/ldaps' // implicit TLS on 636
await using ldap = await connect({
hostname: 'ldap.example.com',
bindDN: 'cn=svc,dc=example,dc=org',
password: env.LDAP_PW
});
const users = await ldap.search({
base: 'ou=people,dc=example,dc=org',
scope: 'sub',
filter: '(&(objectClass=person)(mail=*@example.org))',
attributes: ['cn', 'mail']
});
for (const u of users) console.log(u.dn, u.attributes.mail);StartTLS
LDAP StartTLS is supported on the standard LDAP port (389):
import { connect } from 'edgeport/ldap';
await using ldap = await connect({
hostname: 'ldap.example.com',
port: 389,
tls: 'starttls',
bindDN: 'cn=svc,dc=example,dc=org',
password: env.LDAP_PW
});
const entries = await ldap.search({
base: 'ou=people,dc=example,dc=org',
scope: 'sub',
filter: '(uid=jdoe)',
attributes: ['cn', 'mail']
});LDAP Filters
LDAP search filters are expressed in RFC 4515 syntax:
import { connect } from 'edgeport/ldap';
await using ldap = await connect({
hostname: 'ldap.example.com',
bindDN: 'cn=svc,dc=example,dc=org',
password: env.LDAP_PW
});
const users = await ldap.search({
base: 'ou=people,dc=example,dc=org',
scope: 'sub',
filter: '(&(objectClass=person)(mail=*@example.org))',
attributes: ['cn', 'mail']
});Filter Builders
Build search filters from untrusted input safely. The structured builders carry values
literally, so a *, (, or ) in user input becomes a literal byte on the wire and can
never inject filter syntax. Drop the result straight into search({ filter }) / findOne.
import { connect, and, eq, present } from 'edgeport/ldap';
await using ldap = await connect({
hostname: 'ldap.example.com',
bindDN: 'cn=svc,dc=example,dc=org',
password: env.LDAP_PW
});
const users = await ldap.search({
base: 'ou=people,dc=example,dc=org',
filter: and(eq('objectClass', 'person'), present('mail')) // (&(objectClass=person)(mail=*))
});
const one = await ldap.findOne({
base: 'ou=people,dc=example,dc=org',
filter: eq('uid', userInput)
});Builders: and, or, not, eq, present, gte, lte, approx, substring, contains
(also grouped under a filters namespace). When you assemble a filter string or DN by hand
instead, escape the interpolated value with escapeFilterValue / escapeDN.
authenticate does the bind-search-bind verify flow in one call:
import { authenticate, eq } from 'edgeport/ldap';
const entry = await authenticate({
hostname: 'ldap.example.com',
bindDN: 'cn=svc,dc=example,dc=org',
bindPassword: env.SVC_PW,
base: 'ou=people,dc=example,dc=org',
userFilter: eq('uid', username),
password: submittedPassword
});
if (entry) {
// authenticated; entry.dn is the bound user
}Syslog
import { connect, Severity } from 'edgeport/syslog';
await using log = await connect({
hostname: 'logs.example.com',
port: 6514,
tls: 'implicit',
appName: 'edge-worker'
});
await log.log({
severity: Severity.info,
message: 'request handled',
structuredData: [{ id: 'req@1', params: { ms: '12' } }]
});Severity Shortcuts
info/notice/warn/error/debug delegate to log() with the matching severity:
import { connect } from 'edgeport/syslog';
await using log = await connect({ hostname: 'logs.example.com', appName: 'edge-worker' });
await log.info('request handled');
await log.warn('disk almost full', { facility: 'local0' });
await log.error('request failed', { structuredData: [{ id: 'req@1', params: { code: '500' } }] });SMPP
Send and receive SMS through a carrier's SMSC over SMPP v3.4. Bind as a transmitter, receiver,
or transceiver; submit messages; and iterate inbound deliver_sms - mobile-originated messages
and delivery receipts - over the same session.
import { connect } from 'edgeport/smpp';
await using smpp = await connect({
hostname: 'smsc.example.com',
port: 2775,
systemId: 'esme',
password: env.SMPP_PW,
bindMode: 'transceiver'
});
// submit and get the SMSC message id back; request a delivery receipt
const id = await smpp.submit({
source: 'EDGEPORT',
destination: '12065550111',
message: 'hello from the edge',
registeredDelivery: true
});
// iterate inbound messages and delivery receipts
for await (const inbound of smpp.messages()) {
if (inbound.isDeliveryReceipt) {
const r = inbound.receipt();
if (r.id === id) console.log('delivered:', r.stat); // e.g. 'DELIVRD'
} else {
console.log('MO from', inbound.source, inbound.text());
}
}One-shot Send SMS
import { sendMessage } from 'edgeport/smpp';
const id = await sendMessage({
hostname: 'smsc.example.com',
systemId: 'esme',
password: env.SMPP_PW,
source: 'EDGEPORT',
destination: '12065550111',
message: 'one-shot SMS from a Worker'
});Unicode and Long Messages
Pass dataCoding: DataCoding.Ucs2 for non-GSM text (encoded UTF-16BE). A body over 254 octets
is carried in a message_payload TLV automatically.
import { connect, DataCoding } from 'edgeport/smpp';
await using smpp = await connect({
hostname: 'smsc.example.com',
systemId: 'esme',
password: env.SMPP_PW
});
await smpp.submit({
destination: '12065550111',
message: 'こんにちは',
dataCoding: DataCoding.Ucs2
});TLS is implicit-only (tls: 'implicit'); SMPP has no in-band STARTTLS. The session sends
periodic enquire_link keep-alives and answers the SMSC's, and close() unbinds cleanly.
SIP (RCS)
A SIP user agent over raw TCP/TLS: register, send and receive messages, probe capabilities, subscribe to presence, and run MSRP chat sessions. SIP + MSRP are the protocols the chat side of RCS (the GSMA Universal Profile) rides on, so this is edgeport's RCS-family client - but it targets open SIP infrastructure (Asterisk, FreeSWITCH, Kamailio, OpenSIPS, and cloud SIP trunks like Twilio / Telnyx / Flowroute), not carrier RCS.
[!NOTE] This is a signaling + messaging client. It does not carry voice/video media (RTP/SRTP is UDP, which Workers cannot open), and it does not reach carrier-RCS handsets - that path is gated behind IMS/SIM provisioning, and for businesses it is an HTTPS REST API (Google Jibe / aggregators), which a Worker already calls with
fetch. TLS is implicit-only (tls: 'implicit'); SIP has no in-band STARTTLS.
One-shot Message
A single MESSAGE needs no registration - the server challenges it, edgeport authenticates,
and the proxy routes it - so it fits a normal request-scoped Worker.
import { sendMessage } from 'edgeport/sip';
await sendMessage({
hostname: 'sip.example.com',
username: 'alice',
password: env.SIP_PW,
to: 'bob',
text: 'hello over SIP'
});Register and Receive
To receive inbound messages, REGISTER with RFC 5626 "outbound", which lets a listen-less Worker
receive requests on its own connection (the same trick SMPP deliver_sm uses). Hold the session
in a Durable Object to keep the registration open: an open socket keeps a DO alive up to ~15
minutes, and the session refreshes REGISTER on its own; reconnect on a DO alarm.
import { connect } from 'edgeport/sip';
await using ua = await connect({
hostname: 'sip.example.com',
username: 'alice',
password: env.SIP_PW
});
await ua.register();
await ua.message('bob', 'hi bob');
for await (const m of ua.messages()) {
console.log('from', m.from, m.text());
}Capabilities and Presence
const caps = await ua.options('bob'); // { status, allow: [...], accept: [...] }
await using sub = await ua.subscribePresence('bob');
for await (const note of sub) {
console.log(note.state, note.text()); // Subscription-State + PIDF XML
}MSRP Chat (session mode)
invite() offers an MSRP message session and, on answer, opens the chat over MSRP (RFC 4975);
the Worker is always the active side and dials the peer's MSRP path.
await using chat = await ua.invite('bob');
await chat.send('rich chat over MSRP');
for await (const m of chat.messages()) console.log(m.text());Digest auth (RFC 2617 / 7616) is handled transparently, including the MD5 that Workers WebCrypto lacks - assembled and KAT-verified in the module, the same way SSH assembles ChaCha.
DNS
Resolve DNS over TCP (RFC 1035 + RFC 7766) from a Worker. Three levels: one-shot resolve
helpers, a reusable session that pipelines queries over one connection, and a raw query that
returns the full message (answer, authority, and additional sections) for tooling.
[!IMPORTANT] The default resolver is
1.1.1.1(RESOLVERS.cloudflare) to match ecosystem expectations, but a Worker's outbound TCP to Cloudflare IP ranges - which include1.1.1.1- is blocked by the runtime. Inside a Worker, pass a non-Cloudflare resolver (RESOLVERS.google/RESOLVERS.quad9). UDP is impossible on Workers, so this is DNS-over-TCP only; DNS-over-TLS is available withtls: 'implicit'(port 853).
import { resolve, resolve4, resolveMx, reverse, RESOLVERS } from 'edgeport/dns';
const ips = await resolve4('example.com', { server: RESOLVERS.google }); // string[]
const mx = await resolveMx('example.com', { server: RESOLVERS.google }); // MxRecord[]
const names = await reverse('8.8.8.8', { server: RESOLVERS.google }); // ['dns.google']
// the overloaded resolve() narrows its return on the record type
const aaaa = await resolve('example.com', { type: 'AAAA', server: RESOLVERS.quad9 }); // string[]Session (Pipelined Queries)
import { connect, RESOLVERS } from 'edgeport/dns';
await using dns = await connect({ hostname: RESOLVERS.google });
const [a, mx, txt] = await Promise.all([
dns.query('example.com', 'A'),
dns.query('example.com', 'MX'),
dns.query('example.com', 'TXT')
]);Raw Query (Full Message)
import { query, RESOLVERS } from 'edgeport/dns';
const res = await query(
{ questions: [{ name: 'example.com', type: 'A' }], dnssec: true },
{ hostname: RESOLVERS.quad9 }
);
console.log(res.rcode, res.answers, res.authority, res.additional);NXDOMAIN and "no records" return an empty array (never a throw); a server error rcode
(SERVFAIL, REFUSED, ...) throws a ProtocolError. The raw query exposes the rcode instead
of throwing.
XMPP
XMPP (RFC 6120 / 6121 + XEP-0060 pubsub/PEP) over the raw-TCP core. A concept-based API - you never touch raw XML, but an escape hatch is there when you need it. Port 5222 (plaintext / STARTTLS), 5223 (implicit TLS). SASL PLAIN and SCRAM-SHA-1/256; the strongest mechanism the server offers is chosen.
import { connect, el, text } from 'edgeport/xmpp';
await using xmpp = await connect({
jid: '[email protected]',
password: env.XMPP_PW,
tls: 'starttls' // 'off' | 'starttls' (default) | 'implicit'
});
// presence: friendly states map to XMPP (online, away, busy=dnd, xa, invisible, offline)
await xmpp.setPresence('online', { status: 'at the balcony' });
// send + receive chat
await xmpp.send({ to: '[email protected]', body: 'wherefore art thou' });
for await (const msg of xmpp.messages()) {
console.log(msg.from, msg.body);
break;
}
// roster
await xmpp.addRosterItem('[email protected]', { name: 'Romeo', groups: ['Montague'] });
for (const item of await xmpp.roster()) console.log(item.jid, item.subscription, item.groups);PubSub
import { connect, el, text } from 'edgeport/xmpp';
await using xmpp = await connect({ jid: '[email protected]', password: env.XMPP_PW });
// create a node on a pubsub component, subscribe, publish, receive the event
await xmpp.createNode('pubsub.example.com', 'sensors/temp');
await xmpp.subscribeNode('pubsub.example.com', 'sensors/temp');
const events = xmpp.pubsub()[Symbol.asyncIterator]();
await xmpp.publish('sensors/temp', el('reading', { xmlns: 'urn:example' }, '21.5'), {
service: 'pubsub.example.com'
});
const { payload } = (await events.next()).value;
console.log(text(payload!)); // '21.5'
// PEP (publish to your own account): omit the service
await xmpp.publish(
'urn:xmpp:avatar:metadata',
el('metadata', { xmlns: 'urn:xmpp:avatar:metadata' })
);Raw Stanzas
await xmpp.sendXML("<presence type='unavailable'/>");
const version = await xmpp.iq('get', el('query', { xmlns: 'jabber:iq:version' }), {
to: 'example.com'
});A long-lived session (to receive inbound stanzas) belongs in a Durable Object - an open socket
keeps a DO alive up to ~15 min. For fire-once sends, sendChat binds, sends, and closes:
import { sendChat } from 'edgeport/xmpp';
await sendChat({
jid: '[email protected]',
password: env.XMPP_PW,
to: '[email protected]',
body: 'deploy finished'
});IRC
RFC 1459 / 2812 with IRCv3 extensions (CAP negotiation, SASL, message-tags, server-time). Plaintext 6667 or implicit TLS 6697.
import { connect } from 'edgeport/irc';
await using irc = await connect({ hostname: 'irc.example.com', nick: 'edgebot' });
await irc.join('#edgeport');
await irc.say('#edgeport', 'hello from the edge');
await irc.action('#edgeport', 'waves'); // CTCP ACTION (/me)
for await (const m of irc.messages()) {
if (m.isChannel) console.log(`<${m.from}> ${m.text}`);
if (m.ctcp?.command === 'ACTION') console.log(`* ${m.from} ${m.ctcp.args}`);
break;
}SASL
Pass sasl to run the IRCv3 CAP + SASL PLAIN exchange before registration (only the caps the
server offers are requested):
await using irc = await connect({
hostname: 'irc.example.com',
nick: 'edgebot',
sasl: { user: 'edgeacct', password: env.IRC_PASSWORD },
caps: ['message-tags', 'server-time'] // extra caps to request alongside sasl
});Events, Names, Topic, WHOIS
messages() carries PRIVMSG/NOTICE (with CTCP parsed); everything else - JOIN/PART/QUIT/NICK/
KICK/MODE/TOPIC and numerics - flows through events(). Server PINGs are answered automatically.
for await (const ev of irc.events()) {
if (ev.command === 'JOIN') console.log(`${ev.prefix?.nick} joined ${ev.params[0]}`);
}
const members = await irc.names('#edgeport'); // string[], membership prefixes stripped
await irc.topic('#edgeport', 'welcome'); // set
const current = await irc.topic('#edgeport'); // get
const who = await irc.whois('alice');
await irc.changeNick('edgebot2'); // resolves once the server echoes the NICKRaw Commands
send(command, ...params) applies the trailing-parameter rule to the last argument;
sendRaw(line) is the verbatim escape hatch.
await irc.send('MODE', '#edgeport', '+o', 'alice'); // MODE #edgeport +o alice
await irc.sendRaw('WHO #edgeport');Redis
RESP over TCP, both protocol versions. Plaintext 6379, or implicit TLS against a server with
tls-port configured.
import { connect } from 'edgeport/redis';
await using redis = await connect({ hostname: 'redis.example.com', password: env.REDIS_PASSWORD });
await redis.set('session:abc', JSON.stringify({ user: 7 }), { ex: 3600 });
const session = await redis.getText('session:abc');
const visits = await redis.incr('visits');One-shot Command
import { command } from 'edgeport/redis';
const reply = await command({
hostname: 'redis.example.com',
password: env.REDIS_PASSWORD,
args: ['GET', 'feature:flags']
});
const flags = reply.isNull ? {} : reply.json<Record<string, boolean>>();Typed Commands
Strings, keys, hashes, lists, sets, sorted sets, and scripting have typed methods. send runs
every other command, including the ones a Redis module adds.
await redis.hset('user:7', { name: 'ada', year: 1815 });
await redis.hgetall('user:7'); // { name: 'ada', year: '1815' }
await redis.rpush('queue', 'job-1', 'job-2');
await redis.lrange('queue', 0, -1); // ['job-1', 'job-2']
await redis.zadd('leaders', { ada: 10, alan: 8 });
await redis.zrangeWithScores('leaders', 0, -1, { rev: true });
// [{ member: 'ada', score: 10 }, { member: 'alan', score: 8 }]
for await (const key of redis.scanIterator({ match: 'session:*' })) {
await redis.del(key);
}
await redis.eval('return redis.call("SET", KEYS[1], ARGV[1])', { keys: ['k'], args: ['v'] });
await redis.send('XADD', 'events', '*', 'kind', 'deploy');get and hget return raw bytes so values stay binary-safe; getText and hgetText return the
decoded string. Both give null for a missing key.
Pipelines and Transactions
pipeline sends a batch in one round trip. multi wraps the same batch in MULTI / EXEC so it
applies atomically. In both, a per-command failure lands on that reply's error rather than
discarding the other results, and reading a failed reply's value throws.
const [, count] = await redis.pipeline([
['SET', 'k', 'v'],
['INCR', 'n']
]);
count.number(); // 1
const [balance] = await redis.multi([
['INCRBY', 'balance', 50],
['EXPIRE', 'balance', 3600]
]);Pub/Sub
subscribe resolves once the server has confirmed every channel, so a publish issued afterwards
is delivered.
await using sub = await redis.subscribe('deploys');
for await (const msg of sub) {
console.log(msg.channel, msg.json());
}Pattern subscriptions carry the pattern that matched alongside the channel:
await using sub = await redis.psubscribe('news.*');
for await (const msg of sub) {
console.log(msg.pattern, msg.channel, msg.text());
}RESP3
protocol: 3 runs the HELLO 3 handshake, which Redis 6 and newer support. Replies gain the
richer types (maps, sets, doubles, booleans) and the connection keeps working as a command
connection while subscribed, which RESP2 refuses.
await using redis = await connect({ hostname: 'redis.example.com', protocol: 3 });
redis.serverInfo?.version; // '8.8.2'
await using sub = await redis.subscribe('deploys');
await redis.set('last-seen', '1'); // a RESP2 connection rejects this while subscribedHGETALL and ZRANGE ... WITHSCORES return different shapes on the two versions; hgetall and
zrangeWithScores normalize both, so switching protocol does not change your code.
Raw RESP
encodeCommand, readReply, and the RespValue union are exported for tooling that needs the
codec directly. Every reply also keeps its decoded value on raw.
import { encodeCommand } from 'edgeport/redis';
encodeCommand(['LLEN', 'mylist']); // *2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n
const reply = await redis.send('ZSCORE', 'leaders', 'ada');
reply.raw.kind; // 'bulk' on RESP2, 'double' on RESP3Utilities
edgeport/util is a small set of transport-free helpers the protocol modules share, published
for consumers too. Importing it never pulls in cloudflare:sockets.
| Helper | Purpose |
| ------------------------------------------ | --------------------------------------------------------------------- |
| toHex / fromHex | Bytes <-> lowercase hex. |
| toBase64 / fromBase64 | Bytes <-> base64 (standard or URL-safe; tolerant decode). |
| fromUtf8 / toUtf8 | String <-> UTF-8 bytes (no more new TextEncoder()). |
| encodeJson / decodeJson | Value <-> UTF-8 JSON bytes (tolerant parse to a ProtocolError). |
| randomHex / randomId | CSPRNG-backed random hex tokens and prefixed ids. |
| retry | Run an operation with exponential backoff, retrying transient errors. |
| parseEmailAddress / formatEmailAddress | Parse and format Display Name <local@domain>. |
| withTimeout | Race a promise against a deadline; rejects with TimeoutError. |
import {
toBase64,
fromUtf8,
encodeJson,
randomId,
retry,
parseEmailAddress,
withTimeout
} from 'edgeport/util';
toBase64(bytes, { urlSafe: true }); // url-safe, unpadded
randomId('worker'); // 'worker-3f9a...'
fromUtf8('hi'); // UTF-8 bytes; toUtf8(bytes) decodes back - no TextEncoder/Decoder needed
encodeJson({ ok: true }); // bytes of '{"ok":true}'; decodeJson(bytes) parses it back
parseEmailAddress('Ada Lovelace <[email protected]>');
// { name: 'Ada Lovelace', address: '[email protected]', local: 'ada', domain: 'example.com' }
const res = await withTimeout(fetch('https://example.com'), 5000, 'fetch');
// retry only transient ConnectionError / TimeoutError - never AuthError / ProtocolError
import { connect } from 'edgeport/ssh';
const ssh = await retry(() => connect({ hostname: 'box', username: 'u', password: env.PW }), {
attempts: 4
});The SSH module also gains fingerprint(key), turning the raw key bytes a HostKeyVerifier
receives into the SHA256:... form ssh-keygen -l prints, for trust-on-first-use pinning.
Real-World Recipes
[!NOTE] For larger multi-protocol workflows - mail automation, secure deploy/ops, an HL7 integration engine, device fleet management, resilience/recovery, and more - see ADVANCED_USAGE.md, each backed by an end-to-end integration test in
test/integration/recipes/.
A cron Worker that runs a Remote Command
import { exec } from 'edgeport/ssh';
export default {
async scheduled(_event, env) {
const { code } = await exec({
hostname: env.BOX,
username: 'deploy',
privateKey: { pem: env.SSH_KEY },
command: 'systemctl restart myapp'
});
if (code !== 0) throw new Error('restart failed');
}
};Email an Alert on a Webhook
import { send } from 'edgeport/smtp';
export default {
async fetch(req, env) {
const body = await req.text();
await send({
hostname: env.SMTP_HOST,
auth: { username: env.SMTP_USER, password: env.SMTP_PW },
from: env.SMTP_USER,
to: env.ONCALL,
subject: 'Webhook alert',
text: body
});
return new Response('ok');
}
};Poll a Mailbox and Archive Attachments to SFTP
import { connect as imapConnect } from 'edgeport/imap';
import { putFile } from 'edgeport/sftp';
await using imap = await imapConnect({
hostname: env.IMAP,
auth: { username: env.U, password: env.P }
});
await imap.select('INBOX');
const uids = await imap.search({ unseen: true });
const messages = await imap.fetch(uids, { body: true });
for (const m of messages) {
if (m.body)
await putFile({
hostname: env.SFTP,
username: env.U,
password: env.P,
path: `/archive/${m.uid}.eml`,
data: m.body
});
}Run a Remote Command and Publish the Result to NATS
import { connect as sshConnect } from 'edgeport/ssh';
import { connect as natsConnect } from 'edgeport/nats';
await using ssh = await sshConnect({
hostname: env.BOX,
username: 'deploy',
privateKey: { pem: env.SSH_KEY }
});
const disk = await ssh.df('/'); // parsed rows: { filesystem, sizeKb, usedKb, availKb, ... }
await using nc = await natsConnect({ hostname: env.NATS, token: env.NATS_TOKEN });
await nc.publishJson('telemetry.disk', disk); // JSON-encodes the structured usageBridge MQTT Sensor Readings to Syslog (edge observability)
import { connect as mqttConnect } from 'edgeport/mqtt';
import { connect as syslogConnect, Severity } from 'edgeport/syslog';
await using mqtt = await mqttConnect({ hostname: env.BROKER, clientId: 'edge-bridge' });
await using log = await syslogConnect({
hostname: env.SIEM,
port: 6514,
tls: 'implicit',
appName: 'sensors'
});
for await (const reading of mqtt.subscribe('sensors/#', { qos: 1 })) {
await log.log({
severity: Severity.info,
message: `${reading.topic}=${reading.text()}`
});
}Authorize an Action via LDAP, then Alert over STOMP
import { connect as ldapConnect } from 'edgeport/ldaps';
import { eq } from 'edgeport/ldap';
import { connect as stompConnect } from 'edgeport/stomp';
await using dir = await ldapConnect({
hostname: env.LDAP,
bindDN: env.SVC_DN,
password: env.SVC_PW
});
// eq() carries the value literally, so an untrusted uid can't inject filter syntax
const allowed = await dir.search({ base: 'ou=people,dc=example,dc=org', filter: eq('uid', uid) });
if (allowed.length === 0) {
await using mq = await stompConnect({
hostname: env.MQ,
login: env.MQ_USER,
passcode: env.MQ_PW
});
await mq.send('/queue/security.alerts', `unauthorized action by ${uid}`);
}Pull a File over FTP and Republish to MQTT
import { getFile } from 'edgeport/ftp';
import { connect as mqttConnect } from 'edgeport/mqtt';
const csv = await getFile({
hostname: env.FTP,
username: env.U,
password: env.P,
path: '/exports/prices.csv'
});
await using mqtt = await mqttConnect({ hostname: env.BROKER, clientId: 'price-feed' });
await mqtt.publish('feeds/prices', csv, { qos: 1, retain: true });Error Handling
Every edgeport call rejects with one of four types, all extending EdgeportError:
import { AuthError, ConnectionError, ProtocolError, TimeoutError } from 'edgeport';
try {
await exec({ hostname: 'h', username: 'u', password: 'wrong', command: 'id' });
} catch (err) {
if (err instanceof AuthError) // bad credentials
else if (err instanceof ConnectionError) // could not reach / TLS / dropped
else if (err instanceof ProtocolError) // malformed or unsupported (e.g. no common cipher)
else if (err instanceof TimeoutError) // a deadline elapsed
}Limitations
- No port 25. Cloudflare blocks outbound port 25; use 587 or 465 for SMTP.
- ChaCha20 throughput.
[email protected]is pure-JS (via@noble/ciphers) and slower than the hardware-paced AES-GCM that WebCrypto provides; AES-GCM is preferred during negotiation. Watch Worker CPU limits on very large ChaCha transfers. - No agent forwarding or X11. Local port forwarding is supported via
forwardOut(direct-tcpipreach-through); SOCKS (-D) and remote (-R) forwarding are out of scope because Workers cannot accept inbound connections. - Encrypted private keys: encrypted PKCS#8 (PBES2) and OpenSSH-format keys are
supported; legacy
DEK-InfoPEM andaes-gcm/chacha20-poly1305OpenSSH key ciphers are not (re-encrypt withaes256-ctr). - Time-based rekey is not triggered (byte-volume and server-initiated rekeys are).
- FTPS is not provided. The Workers
startTlsAPI exposes no TLS session export/import, so the FTPS data connection cannot resume the control channel's TLS session - which strict servers (e.g. vsftpdrequire_ssl_reuse=YES) mandate. Shipping it would only work against servers that disable that protection, so plainedgeport/ftpis provided and FTPS is deferred until the runtime gains TLS session control. - FTP is passive-mode only (Workers cannot accept the inbound connections active mode needs).
- LDAP SASL is not implemented (simple bind over TLS is). The one mechanism that would add capability over simple bind, SASL EXTERNAL, needs a TLS client certificate, which the Workers socket API cannot present; PLAIN is equivalent to simple bind, and SCRAM/DIGEST/GSSAPI are rarely required. NATS auth is fully covered (token / user-pass / nkey / JWT); NATS does not use SASL. MQTT/STOMP/Syslog authenticate via TLS + username-password.
- DNS is TCP-only, and
1.1.1.1is unreachable from a Worker. Workers cannot open UDP, soedgeport/dnsspeaks DNS-over-TCP (RFC 7766); DNS-over-TLS works viatls: 'implicit'. The default resolver1.1.1.1is a Cloudflare IP, which the runtime blocks outbound - passRESOLVERS.google/RESOLVERS.quad9from inside a Worker. - Redis is standalone-only. Cluster redirections (
-MOVED/-ASK) surface asProtocolErrorrather than being followed, and Sentinel discovery and sharded Pub/Sub (SSUBSCRIBE) are out of scope.protocol: 3is not negotiated down either: a server that cannot speak RESP3 fails the connect instead of silently falling back, because the two versions return different reply shapes for the same command. RESP integers decode to JS numbers, so a value past 2^53 loses precision; RESP3 big numbers decode tobigint. - XMPP and IRC over TLS are proven only against a real trusted-cert server. workerd validates certificates, so the self-signed docker servers exercise the plaintext path end-to-end while STARTTLS / implicit-TLS for both is unit-tested; both connect fine to a public TLS server.
Advanced: Building-Block Exports
Beyond the protocol modules, edgeport publishes the low-level transport core and the SSH building blocks it is assembled from, for tooling that needs them directly:
| Import | Provides |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| edgeport/core | The raw framed TCP transport (connect, CoreSocket, FramedReader / FramedWriter, startTls) plus the shared error vocabulary - to speak a protocol edgeport does not ship on the same buffered transport the built-ins use |
| edgeport/wire | SSH binary wire codecs (SshReader, SshWriter, toMpintBody) |
| edgeport/crypto | hashes/HMAC, the SSH packet ciphers (createPacketCipher, cipherSizes), host/user keys (verifyHostSignature, loadUserKey) |
| edgeport/kex | KEXINIT negotiation, the exchange hash + key schedule, createKex, and the curve25519/nistp256 namespaces |
| edgeport/auth | SSH user authentication (authenticate) |
import { connect } from 'edgeport/core';
import { SshReader, SshWriter } from 'edgeport/wire';
import { createPacketCipher, verifyHostSignature } from 'edgeport/crypto';
import { negotiate, createKex } from 'edgeport/kex';
// a raw framed TCP client for a protocol edgeport does not ship (finger, RFC 1288)
await using sock = await connect({ hostname: 'example.com', port: 79 });
await sock.writer.writeLine('');
const line = await sock.reader.readLine();These are stable but lower-level; most applications only need the protocol modules above.
edgeport/core is the one module that imports cloudflare:sockets - every protocol is built on it.
API Reference
Full generated API docs: see the published TypeDoc site (built from source by
bun run docs:build).
Contributing
bun install
bun run typecheck
bun run test # gate: unit + KAT, hermetic
docker compose -f docker/compose.yml up -d --wait
INTEGRATION=1 bun run test # integration under workerd vs real servers
docker compose -f docker/compose.yml down -vLicense
MIT (c) Gregory Mitchell 2026. See LICENSE file for details.
