🛜 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.
Workers can open raw TCP sockets, but every existing SSH/mail client depends on Node APIs the runtime does not provide. edgeport is:
cloudflare:sockets;
every protocol builds on it. Nothing depends on Node.edgeport/ssh, edgeport/smtp, ...).workerd against
Dockerized servers (OpenSSH, Dropbear, GreenMail, NATS with JetStream, Mosquitto, ActiveMQ,
OpenLDAP, an FTP server, an SMPP SMSC simulator, a Kamailio SIP server, and a WebSocket echo
server) - not mocks.exec, interactive shell, subsystems.for await message iteration).submit_sm, and inbound deliver_sm / delivery receipts.MESSAGE, OPTIONS, presence, and MSRP chat.sendSms).edgeport/util): hex/base64 codecs, random ids, and retry-with-backoff.AuthError, ConnectionError, ProtocolError, TimeoutError.| Category | Supported |
|---|---|
| Key Exchange | curve25519-sha256, ecdh-sha2-nistp256 |
| Host / User Keys | Ed25519, ECDSA-P256, RSA-SHA2-256/512 |
| Ciphers | aes256-gcm@openssh.com, aes128-gcm@openssh.com, chacha20-poly1305@openssh.com, 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.
bun add edgeport
# or: npm install edgeport
edgeport targets the Workers runtime. Use it inside a Worker, not in plain Node.
import { exec } from 'edgeport/ssh';
export default {
async fetch(): Promise<Response> {
const { stdout, code } = await exec({
hostname: 'example.com',
username: 'deploy',
password: env.SSH_PASSWORD,
command: 'uptime'
});
return new Response(`exit ${code}\n${new TextDecoder().decode(stdout)}`);
}
};
import { exec } from 'edgeport/ssh';
const { stdout, stderr, code } = await exec({
hostname: 'host',
username: 'user',
privateKey: { pem: env.SSH_KEY }, // PKCS8 PEM, or pass a CryptoKey
command: 'ls -la /var/log'
});
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` scope
await using ssh = await connect({ hostname: 'host', username: 'user', password: env.PW });
await using shell = await ssh.shell();
await shell.write(new TextEncoder().encode('echo hi\n'));
const reader = shell.stdout.getReader();
const { value } = await reader.read();
console.log(new TextDecoder().decode(value));
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 */
}
});
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 { stdout, code } = await sudoExec({
hostname: 'host',
username: 'user',
password: env.PW,
command: 'systemctl restart myapp'
});
console.log(code, new TextDecoder().decode(stdout));
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 { stdout } = await sudo(ssh, 'whoami', { password: env.PW });
console.log(new TextDecoder().decode(stdout)); // "root"
await using ssh = await connect({
hostname: 'host',
username: 'user',
password: env.PW,
algorithms: { cipher: ['chacha20-poly1305@openssh.com'] }
});
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 }
});
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 now
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).
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'
});
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', new TextEncoder().encode('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');
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 });
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');
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.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-atomic
removeAll 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.
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: 'postmaster@example.com', password: env.SMTP_PW },
from: 'postmaster@example.com',
to: ['ops@example.com'],
subject: 'Deploy finished',
text: 'The 14:00 deploy completed successfully.'
});
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' });
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: 'welcome@yourdomain.com', // domain must be onboarded to Email Sending
to: 'user@example.com', // any valid email address rather than verified recipient
subject: 'Hello',
text: 'Sent from a Worker over edgeport SMTP.'
});
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' }]
});
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'); // '15551234567@txt.att.net'
await sendSms({
hostname: 'smtp.example.com',
auth: { username: 'me@example.com', password: env.SMTP_PW },
from: 'me@example.com',
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.
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 });
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
});
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);
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 }
});
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;
import { connect } from 'edgeport/ws';
const ws = await connect('wss://stream.example.com/feed');
ws.sendJson({ subscribe: 'ticks' });
for await (const msg of ws) {
if (msg.type === 'text') {
const event = msg.json<{ price: number }>();
// handle the parsed event here
}
}
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 = JSON.parse(new TextDecoder().decode(msg.data));
// handle the order here
}
// request-reply
const reply = await nc.request('time.now', '', { timeoutMs: 1000 });
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 });
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 = JSON.parse(new TextDecoder().decode(msg.data));
// one member of the 'workers' queue group receives each message
}
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
}
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().
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, new TextDecoder().decode(m.payload));
// or tunnel MQTT through a WebSocket broker endpoint
const overWs = await connectWebSocket('wss://broker.example.com:8884/mqtt', {
clientId: 'edge-ws'
});
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, new TextDecoder().decode(m.payload));
}
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' will
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().
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?.();
}
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
});
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 delivered
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
}
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', new TextEncoder().encode('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');
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' });
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 '/'
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);
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 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']
});
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
}
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' } }]
});
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' } }] });
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());
}
}
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'
});
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.
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.
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.
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'
});
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());
}
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
}
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.
edgeport/util is a small set of transport-free helpers the protocol modules share, published
for consumers too: byte-encoding codecs, random ids off the CSPRNG, and a retry-with-backoff
tuned to edgeport's error vocabulary.
import { toHex, fromHex, toBase64, fromBase64, randomId, retry } from 'edgeport/util';
toHex(new Uint8Array([0xde, 0xad])); // 'dead'
toBase64(bytes, { urlSafe: true }); // url-safe, unpadded
fromBase64('data:text/plain;base64,aGk='); // tolerant: strips the data-uri prefix, restores padding
randomId('worker'); // 'worker-3f9a...'
// 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
});
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/.
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');
}
};
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');
}
};
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
});
}
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 usage
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}=${new TextDecoder().decode(reading.payload)}`
});
}
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}`);
}
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 });
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
}
chacha20-poly1305@openssh.com 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.forwardOut
(direct-tcpip reach-through); SOCKS (-D) and remote (-R) forwarding are out of scope
because Workers cannot accept inbound connections.DEK-Info PEM and aes-gcm/chacha20-poly1305 OpenSSH key ciphers
are not (re-encrypt with aes256-ctr).startTls API exposes no TLS session export/import,
so the FTPS data connection cannot resume the control channel's TLS session - which strict
servers (e.g. vsftpd require_ssl_reuse=YES) mandate. Shipping it would only work against
servers that disable that protection, so plain edgeport/ftp is provided and FTPS is
deferred until the runtime gains TLS session control.Beyond the protocol modules, edgeport publishes the lower-level SSH building blocks it is assembled from, for tooling that needs them directly:
| Import | Provides |
|---|---|
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 { SshReader, SshWriter } from 'edgeport/wire';
import { createPacketCipher, verifyHostSignature } from 'edgeport/crypto';
import { negotiate, createKex } from 'edgeport/kex';
These are stable but lower-level; most applications only need the protocol modules above.
Full generated API docs: see the published TypeDoc site (built from source by
bun run docs:build).
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 -v
MIT (c) Gregory Mitchell 2026. See LICENSE file for details.