edgeport - v1.0.3
    Preparing search index...

    edgeport - v1.0.3

    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.

    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 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.
    • SSH transport, auth, and connection layers: exec, interactive shell, 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 await message 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 inbound deliver_sm / delivery receipts.
    • SIP / RCS user agent over TCP/TLS: register, MESSAGE, OPTIONS, presence, and MSRP chat.
    • Email-to-SMS carrier-gateway addressing layered on SMTP (sendSms).
    • Utilities (edgeport/util): hex/base64 codecs, random ids, and retry-with-backoff.
    • One uniform error vocabulary: 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.

    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.

    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
    });
    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/.

    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
    }
    • No port 25. Cloudflare blocks outbound port 25; use 587 or 465 for SMTP.
    • ChaCha20 throughput. 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.
    • No agent forwarding or X11. Local port forwarding is supported via forwardOut (direct-tcpip reach-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-Info PEM and aes-gcm/chacha20-poly1305 OpenSSH key ciphers are not (re-encrypt with aes256-ctr).
    • Time-based rekey is not triggered (byte-volume and server-initiated rekeys are).
    • FTPS is not provided. The Workers 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.
    • 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.

    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.