mrkeyoor.com_
Thu 06 Aug 07:40 UTC
npmSecurityupdated 06 Aug 2026

basic-ftp

basic-ftp is an FTP and FTPS client for Node.js with a promise-based API and zero runtime dependencies. You create a Client, call access() with host, credentials and secure: true for FTP over TLS, then use methods like list(), uploadFrom(), downloadTo(), ensureDir() and uploadFromDir() that read and write local paths or streams. It is written in TypeScript and ships type declarations. Its own README opens with an advisory telling you to use HTTPS or SFTP instead if you have the choice, because FTP itself offers no security and has reliability quirks. Active mode is not supported; passive mode only, including over IPv6.

Verdict

The best FTP client on npm and the one to reach for when FTP is forced on you: small, typed, dependency-free, and unusually careful about protocol-level attacks. Just do not read that as an endorsement of FTP, and keep it patched rather than pinned.

API stability4/5The Client surface has barely moved since the 5.x line in 2022; the single 6.0.0 breaking change was flipping separate transfer hosts off by default, and 6.1.0 turned a silently truncated upload into a thrown error.
Docs4/5The README is a complete API reference with sections on error handling, progress tracking, and extending the list parser, and it warns you off FTP up front; there is no docs site, cookbook, or migration guide.
Maintenance4/56.2.0 shipped the same week as this review and open issues sit at 9 (11 counting PRs), but this is a one-maintainer project that was quiet from 2022 until a burst of security releases in early 2026.
Ecosystem3/5No plugins or adapters of its own, which suits a leaf library, though it is depended on widely: get-uri pulls it in under pac-proxy-agent and proxy-agent, so it reaches Puppeteer-style stacks without anyone choosing it.

Use it if

  • A partner, bank, or legacy appliance only exposes FTP or FTPS and you genuinely cannot move them to SFTP or HTTPS
  • You need whole-directory operations (uploadFromDir, downloadToDir, ensureDir, clearWorkingDir) rather than hand-rolling recursive walks over raw FTP commands
  • You want the transfer client to add nothing to your dependency tree: it has zero dependencies and installs about 9 KB gzipped
  • You are on TypeScript and want typed FileInfo results and typed access options instead of callback-era FTP libraries with hand-written type stubs
Skip it if

Setup reality

npm install basic-ftp and there is nothing else to install, no native build, no peer dependency. The friction is the protocol, not the package. Most real debugging starts with client.ftp.verbose = true so you can read the actual command and response pairs, because FTP servers disagree about almost everything. Since 6.0.0 the client refuses a server that redirects transfers to a different host, which blocks FTP bounce attacks but will break you against the rare server that legitimately does it until you pass allowSeparateTransferHost: true. Implicit FTPS needs secure: "implicit" and port 990, and 6.0.2 specifically fixed TLS session resumption against the Node.js releases carrying the CVE-2026-48934 fix. Note that a large share of the weekly download count arrives transitively through get-uri, which pins the 5.x range and sits under pac-proxy-agent and proxy-agent, so those numbers are not 26 million people choosing 6.x.

Patterns

Connect over FTPS and close cleanlyconnect-ftps

import { Client } from "basic-ftp";

const client = new Client(30_000);
try {
  await client.access({
    host: "ftp.example.com",
    user: "deploy",
    password: process.env.FTP_PASSWORD,
    secure: true,
  });
  console.log(await client.pwd());
} finally {
  client.close();
}

secure: true is explicit FTPS on port 21. Use secure: "implicit" with port 990 for legacy implicit FTPS. Without secure, credentials cross the wire in plaintext.

List a remote directorylist-directory

const files = await client.list("/incoming");
for (const f of files) {
  console.log(f.isDirectory ? "dir " : "file", f.name, f.size);
}

Only MLSD, Unix, and DOS listing formats are parsed. Against an odd server you assign your own function to client.parseList rather than patching the library.

Upload a local fileupload-file

await client.ensureDir("/incoming/2026-08");
await client.uploadFrom("./build/report.csv", "report.csv");

ensureDir creates every missing directory and leaves the working directory there, so the remote path in uploadFrom is relative to it afterwards.

Download to a local path or streamdownload-file

await client.downloadTo("./tmp/report.csv", "/outgoing/report.csv");

// or straight into a stream
import { createWriteStream } from "node:fs";
await client.downloadTo(createWriteStream("./tmp/copy.csv"), "/outgoing/report.csv");

An existing local file is overwritten. If you pass a stream, basic-ftp does not close it for you when the transfer is part of a longer pipeline.

Push and pull whole directoriessync-directory

// upload the contents of a local folder
await client.uploadFromDir("./dist", "/var/www/html");

// pull a remote folder down
await client.downloadToDir("./backup", "/var/www/html");

uploadFromDir overwrites files with matching names and reuses directories but never deletes anything remote; call clearWorkingDir() first if you want a clean replace.

Report transfer progresstrack-progress

client.trackProgress((info) => {
  console.log(info.type, info.name, info.bytes, "of session", info.bytesOverall);
});

await client.uploadFrom("./big.tar.gz", "big.tar.gz");

client.trackProgress(); // stop reporting

bytesOverall counts every transfer since the last trackProgress call, so re-registering the handler is also how you reset the running total.

Resume an interrupted uploadresume-upload

const already = await client.size("big.tar.gz").catch(() => 0);
if (already > 0) {
  await client.appendFrom("./big.tar.gz", "big.tar.gz", { localStart: already });
} else {
  await client.uploadFrom("./big.tar.gz", "big.tar.gz");
}

size() throws if the remote file is absent, hence the catch. Since 6.1.0 an upload whose local file changes mid-read throws instead of reporting a short upload as success.

Resume an interrupted downloadresume-download

import { statSync, existsSync } from "node:fs";

const path = "./tmp/big.tar.gz";
const offset = existsSync(path) ? statSync(path).size : 0;
await client.downloadTo(path, "big.tar.gz", offset);

The third argument is the remote start offset and it is applied to the local destination too, so the partial file is appended to rather than truncated.

Tell a server rejection apart from a dead connectionerror-handling

import { FTPError } from "basic-ftp";

try {
  await client.remove("/outgoing/missing.csv");
} catch (err) {
  if (err instanceof FTPError) {
    console.warn("server said no:", err.code, err.message);
  } else {
    console.error("connection lost, must reconnect");
    await client.access(options);
  }
}

An FTPError leaves the connection usable. A timeout or socket error closes the client, and every later call fails until you call access() again; client.closed tells you which state you are in.

See the raw FTP conversationverbose-logging

client.ftp.verbose = true;

// or send it to your own logger instead of the console
client.ftp.log = (message) => logger.debug({ ftp: message }, "ftp");

client.ftp.log is called whether or not verbose is true, so overriding it is the way to keep FTP chatter out of stdout while still recording it.

Opt back into a separate transfer hostallow-transfer-host

const client = new Client(30_000, { allowSeparateTransferHost: true });

Off by default since 6.0.0 because a server redirecting transfers to another IP is the FTP bounce attack pattern. Only enable it for a specific server you trust that genuinely requires it.

Run many transfers without breaking the connectionsequential-transfers

// one connection, strictly sequential
for (const file of files) {
  await client.uploadFrom(file.local, file.remote);
}

// concurrency means one Client per worker
const clients = await Promise.all(
  [0, 1, 2].map(async () => {
    const c = new Client(30_000);
    await c.access(options);
    return c;
  }),
);

Never fire Promise.all over one Client: FTP allows a single request at a time on a control connection, and overlapping calls corrupt the response stream.

Alternatives

PackageRegistryPick it when
ssh2-sftp-clientnpmThe other side speaks SSH, which is what you should be asking for before writing any FTP code.
ssh2npmYou need the raw SSH connection for exec or port forwarding as well as file transfer.
webdavnpmThe remote store is Nextcloud, SharePoint, or another HTTP-based file server rather than FTP.