๐ 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, a CoreDNS resolver, an
ejabberd XMPP server, an Ergo IRC server, a Redis 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.resolve, typed record helpers, reverse lookups, and raw queries.sendSms).edgeport/util): hex/base64 codecs, random ids, retry-with-backoff, email-address
parsing, and promise deadlines.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 { stdoutText, code } = await exec({
hostname: 'example.com',
username: 'deploy',
password: env.SSH_PASSWORD,
command: 'uptime'
});
return new Response(`exit ${code}\n${stdoutText}`);
}
};
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 bytes
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
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!));
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 { 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"
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', '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.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-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 });
for (const m of messages) console.log(m.text()); // decoded body; m.body keeps the raw bytes
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); // raw bytes
const firstText = await pop.retrieveText(1); // decoded string, no TextDecoder needed
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' });
// 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
}
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 (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 = msg.json();
// 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, m.text());
// 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, m.text());
}
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', '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.
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.
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 include 1.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 with tls: '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[]
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')
]);
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 (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: 'juliet@example.com',
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: 'romeo@example.com', body: 'wherefore art thou' });
for await (const msg of xmpp.messages()) {
console.log(msg.from, msg.body);
break;
}
// roster
await xmpp.addRosterItem('romeo@example.com', { name: 'Romeo', groups: ['Montague'] });
for (const item of await xmpp.roster()) console.log(item.jid, item.subscription, item.groups);
import { connect, el, text } from 'edgeport/xmpp';
await using xmpp = await connect({ jid: 'sensor@example.com', 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' })
);
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: 'bot@example.com',
password: env.XMPP_PW,
to: 'ops@example.com',
body: 'deploy finished'
});
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;
}
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
});
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 NICK
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');
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');
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>>();
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.
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]
]);
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());
}
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 subscribed
HGETALL and ZRANGE ... WITHSCORES return different shapes on the two versions; hgetall and
zrangeWithScores normalize both, so switching protocol does not change your code.
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 RESP3
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 <ada@example.com>');
// { name: 'Ada Lovelace', address: 'ada@example.com', 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.
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}=${reading.text()}`
});
}
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.1.1.1.1 is unreachable from a Worker. Workers cannot open UDP, so
edgeport/dns speaks DNS-over-TCP (RFC 7766); DNS-over-TLS works via tls: 'implicit'. The
default resolver 1.1.1.1 is a Cloudflare IP, which the runtime blocks outbound - pass
RESOLVERS.google / RESOLVERS.quad9 from inside a Worker.-MOVED / -ASK) surface as
ProtocolError rather than being followed, and Sentinel discovery and sharded Pub/Sub
(SSUBSCRIBE) are out of scope. protocol: 3 is 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 to bigint.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.
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.