@ontemper/sftp
v1.1.0
Published
An SFTP client with structured logging.
Readme
Temper SFTP Client
A TypeScript SFTP client with structured logging and distributed tracing capabilities, built on top of ssh2-sftp-client and integrated with unnbound-logger-sdk.
Features
- Full SFTP Operations: Complete set of SFTP operations including file transfer, directory management, and file system operations
- Structured Logging: Built-in integration with
unnbound-logger-sdkfor comprehensive operation tracking - Distributed Tracing: Automatic span creation for all SFTP operations with detailed payload information
- Type Safety: Full TypeScript support with comprehensive type definitions
- Performance Monitoring: Built-in metrics collection for file sizes, operation counts, and timing
- Flexible Configuration: Support for various authentication methods and connection options
Installation
npm install @ontemper/sftp
# or
bun add @ontemper/sftp
# or
yarn add @ontemper/sftpFrom unnbound-sftp-sdk
This package was published as unnbound-sftp-sdk up to 1.0.28. That release stays on npm, frozen, for existing
workflows. @ontemper/[email protected] has the same client under new names: UnnboundSftpClient is TemperSftpClient,
UnnboundSftpClientOptions is TemperSftpClientOptions, and UnnboundSftpConnectOptions is
TemperSftpConnectOptions. Span names and log payloads do not change. 1.0.0 also carries the changes listed under
1.0.0 in the CHANGELOG, including host-key pinning from #3029, and sends the SSH ident temper by default.
Quick Start
import { TemperSftpClient } from '@ontemper/sftp';
import { logger } from 'unnbound-logger-sdk';
const sftp = new TemperSftpClient({
host: 'your-sftp-server.com',
port: 22,
username: 'your-username',
privateKey: 'your-private-key', // or use password
hostKeyFingerprints: ['SHA256:base64-fingerprint-without-padding'],
});
// Connect to the SFTP server
await sftp.connect();
// Get current working directory
const currentPath = await sftp.cwd();
console.log('Current path:', currentPath);
// Create a directory
await sftp.mkdir('./uploads', true); // recursive
// Upload a file
const fileContent = Buffer.from('Hello, World!');
await sftp.put(fileContent, './uploads/hello.txt');
// Download a file
const downloadedContent = await sftp.get('./uploads/hello.txt');
console.log('Downloaded:', downloadedContent.toString());
// List files in a directory
const files = await sftp.list('./uploads');
console.log(
'Files:',
files.map((f) => f.name),
);
// Close the connection
await sftp.close();API Reference
Constructor
new TemperSftpClient(options: TemperSftpClientOptions)Options
| Property | Type | Required | Description |
| ---------------------- | ---------------- | -------- | ----------------------------------------------------------------- |
| host | string | Yes | SFTP server hostname |
| port | number | No | SFTP server port (default: 22) |
| username | string | No | Username for authentication |
| password | string | No | Password for authentication |
| privateKey | string | No | Private key for key-based authentication |
| passphrase | string | No | Passphrase for encrypted private keys |
| hostKeyFingerprints | string[] | No | Accepted server host-key SHA256 fingerprints |
| hostVerifier | function | No | Custom host-key check; with fingerprints, both must accept |
| hostHash | string | No | Hash applied to the key before hostVerifier (e.g. sha256) |
| allowUnpinnedHostKey | boolean | No | Explicitly allow an unpinned Temper-hosted connection |
| maxBufferBytes | number | No | Maximum file size for get (default: 256 MiB) |
| streamChunkBytes | number | No | Chunk size: 32 KiB–1 MiB; default upload 64 KiB, download 256 KiB |
| streamConcurrency | number | No | Requests per window: 1–64; default upload 32, download 16 |
| localHostname | string | No | Local hostname for hostbased authentication |
| localUsername | string | No | Local username for hostbased authentication |
| keepaliveInterval | number | No | SSH keepalive interval in milliseconds (0 to disable) |
| keepaliveCountMax | number | No | Max unanswered keepalive packets before disconnection |
| readyTimeout | number | No | SSH handshake timeout in milliseconds |
| localAddress | string | No | Local network interface IP address |
| localPort | number | No | Local port number |
| timeout | number | No | Socket timeout in milliseconds |
| ident | string | No | Server software identifier (default: 'temper') |
| logger | UnnboundLogger | No | Custom logger instance (uses default if not provided) |
From a platform sftp Resource
The package does not depend on @ontemper/resources, but the constructor accepts a resolved revision-2 sftp record.
It maps the record's auth variant (password, private_key, or private_key_and_password) to ssh2 options. The
two-factor variant uses ssh2's authentication handler to send the password after public-key authentication. It also
maps the record's algorithms and verifies hostKeyFingerprints.
import { env, SftpResource } from '@ontemper/resources';
import { TemperSftpClient } from '@ontemper/sftp';
const record = await env.resources.resolve('partner-sftp', SftpResource);
const sftp = new TemperSftpClient(record);
await sftp.connect();Connection Management
connect(): Promise<void>
Establishes a connection to the SFTP server.
await sftp.connect();Server host-key pinning
Host-key pinning comes from #3029. Set hostKeyFingerprints to the fingerprints that you trust. The client rejects the connection before authentication if the server offers a different key. The extra sessions that downloadDir and uploadDir open check the same fingerprints.
const sftp = new TemperSftpClient({
host: 'sftp.gotemper.com',
username: 'myuser',
password: process.env.SFTP_PASSWORD,
hostKeyFingerprints: ['SHA256:base64-fingerprint-without-padding'],
});TEMPER_SFTP_HOST_KEY_FINGERPRINTS supplies a comma-separated fallback only for Temper-hosted endpoints (sftp.gotemper.com and *.sftp.dev.gotemper.com). Those endpoints reject unpinned connections. Set allowUnpinnedHostKey: true only when you explicitly accept that risk. A caller-supplied hostVerifier also satisfies this requirement, so it must enforce your own host-key trust policy; with hostKeyFingerprints set, both must accept. The environment fallback is ignored for other hosts, which connect without pinning for compatibility; configure hostKeyFingerprints explicitly when another server operator publishes stable fingerprints.
For Temper-hosted SFTP, copy the published fingerprint from Settings → SFTP access. For another server, obtain its fingerprint from its operator. The following command is valid only when it runs on the server whose host key you are pinning:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256close(): Promise<boolean>
Closes the SFTP connection.
const closed = await sftp.close();File Operations
Use downloadFile, uploadFile, downloadDir, and uploadDir for anything larger than a few megabytes. They stream between disk and the server with up to 64 requests in flight per file. With the defaults, downloadDir and uploadDir move up to 12 files at once (three per session on four sessions), so a server can see up to 768 outstanding requests. get reads with pipelined requests but holds the whole file in memory, up to maxBufferBytes. put uses one request at a time; against a server about 85 ms away, that is about 0.4 MB/s. Both log the full file content. getStream and putStream pipeline without holding the file.
downloadFile(remotePath: string, localPath: string, options?: { useFastget?: boolean }): Promise<number>
Downloads one file to a local path with pipelined reads. Resolves to the byte count. Logs carry paths and size, not content. { useFastget: false } streams the file with get instead, for a server that mishandles parallel requests.
const bytes = await sftp.downloadFile('/inbound/orders.csv', '/tmp/orders.csv');uploadFile(localPath: string, remotePath: string, options?: { useFastput?: boolean }): Promise<number>
Uploads one local file with pipelined writes. Resolves to the byte count. Logs carry paths and size, not content. { useFastput: false } streams the file with put instead, for a server that mishandles parallel requests.
await sftp.uploadFile('/tmp/result.csv', '/outbound/result.csv');get(path: string): Promise<Buffer>
Downloads a small file into memory. The client rejects a remote file larger than maxBufferBytes (256 MiB by default): before downloading when the server reports the size, otherwise as soon as the limit is exceeded. Use getStream or downloadFile for large files.
const content = await sftp.get('/remote/path/file.txt');put(input: Buffer, path: string): Promise<string>
Uploads an existing Buffer. The SDK enforces no size limit; the caller controls how much it holds in memory. Use putStream for large files.
const data = Buffer.from('file content');
await sftp.put(data, '/remote/path/file.txt');getStream(path: string): NodeJS.ReadableStream
Streams a remote file through bounded, concurrent reads, delivered in file-offset order. The default window is 16 × 256 KiB (4 MiB). Consumer backpressure prevents the next window from being read.
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(sftp.getStream('/remote/path/large-file.dat'), createWriteStream('./large-file.dat'));putStream(path: string, input: NodeJS.ReadableStream): Promise<string>
Streams a local file through bounded, vectorized writes. The default batch is 32 × 64 KiB (2 MiB). The SDK waits for every write in a batch before reading another batch. A failed upload ends the connection so the server can discard its incomplete transfer.
import { createReadStream } from 'node:fs';
await sftp.putStream('/remote/path/large-file.dat', createReadStream('./large-file.dat'));The get and put methods hold the whole file in one Buffer. get enforces maxBufferBytes, which defaults to 256 MiB; put has no SDK limit. Use getStream and putStream for large files so file contents are not held in one Buffer. get and getStream share one pipelined reader (set streamConcurrency: 1 for a server that mishandles parallel reads); when the server reports no file size or a size of 0, they read until the end of the file. put delegates the buffer upload to ssh2-sftp-client.
streamChunkBytes and streamConcurrency tune both streaming directions. Values must be integers within the documented bounds; invalid values throw a RangeError naming the option. The transfer window uses at most their product in bytes (up to 64 MiB). This excludes caller-owned source buffers and SSH transport buffers: supply a backpressure-aware source rather than a preloaded whole-file buffer. The SDK creates no client-side temporary transfer files; Temper-hosted uploads use the server-side spool covered in the linked capacity documentation. Downloads use the size reported at the start; premature EOF is an error.
Run the local 10 MiB benchmark with a 75 ms delay per SFTP request:
pnpm --filter @ontemper/sftp exec tsx test/streaming-benchmark.tsIt reports elapsed upload/download times and observed request concurrency for streamConcurrency: 1 versus defaults, without a timing-based pass/fail threshold.
One representative run on 2026-09-06 produced:
| Direction | streamConcurrency: 1 | Default window | Observed in-flight requests |
| --------- | ---------------------: | -------------: | --------------------------: |
| Upload | 36.8 s | 1.25 s | 32 |
| Download | 27.7 s | 2.11 s | 16 |
This benchmark reports elapsed time and observed request concurrency in a local latency simulation. It does not verify byte order, define hosted endpoint capacity, or define an SLA. The independent test/streaming.test.ts suite verifies ordered, byte-identical transfers with SHA-256. See the hosted server's deployment, scaling, and capacity evidence for server-side limits and deployed observations.
append(input: Buffer | NodeJS.ReadableStream, path: string): Promise<string>
Appends data to an existing file.
The client method works only when the remote server supports append. Temper hosted SFTP rejects append with SSH_FX_OP_UNSUPPORTED; use create or truncate writes there. See the server operation table.
const additionalData = Buffer.from('more content');
await sftp.append(additionalData, '/remote/path/file.txt');Directory Operations
list(path: string, filter?: ListFilterFunction): Promise<FileInfo[]>
Lists files and directories in the specified path.
const files = await sftp.list('/remote/directory');
const onlyFiles = await sftp.list('/remote/directory', (item) => item.type === '-');mkdir(path: string, recursive?: boolean): Promise<string>
Creates a directory.
await sftp.mkdir('/remote/new-directory');
await sftp.mkdir('/remote/nested/path', true); // recursivermdir(path: string, recursive?: boolean): Promise<string>
Removes a directory.
await sftp.rmdir('/remote/empty-directory');
await sftp.rmdir('/remote/directory-with-files', true); // recursiveuploadDir(sourcePath: string, destinationPath: string, options?: UploadDirOptions): Promise<string>
Uploads an entire directory. Files move with pipelined writes over up to four SFTP sessions, three files per session at a time; nested directories are created first. Set connections to change the session count. When the server refuses an extra session, the transfer continues on the sessions it has. { useFastput: false } streams files with put on one session, up to 10 at once. Use it for a server that mishandles parallel requests: ssh2-sftp-client warns that such servers can truncate or corrupt pipelined transfers.
await sftp.uploadDir('./local/directory', '/remote/destination');downloadDir(sourcePath: string, destinationPath: string, options?: DownloadDirOptions): Promise<string>
Downloads an entire directory. Files move with pipelined reads over up to four SFTP sessions, three files per session at a time; a symlinked file downloads from its target. Set connections to change the session count. When the server refuses an extra session, the transfer continues on the sessions it has. { useFastget: false } streams files with get on one session, up to 10 at once. Use it for a server that mishandles parallel requests: ssh2-sftp-client warns that such servers can truncate or corrupt pipelined transfers.
await sftp.downloadDir('/remote/directory', './local/destination');File System Operations
exists(path: string): Promise<false | FileInfoType>
Checks if a file or directory exists.
const exists = await sftp.exists('/remote/path/file.txt');
// Returns: false, 'd' (directory), '-' (file), or 'l' (symlink)stat(path: string): Promise<FileStats>
Gets file or directory statistics.
const stats = await sftp.stat('/remote/path/file.txt');
console.log('Size:', stats.size);
console.log('Modified:', stats.modifyTime);realPath(path: string): Promise<string>
Resolves the real path of a file or directory.
const realPath = await sftp.realPath('/remote/symlink');cwd(): Promise<string>
Gets the current working directory.
const currentDir = await sftp.cwd();delete(path: string, noErrorOK?: boolean): Promise<string>
Deletes a file.
await sftp.delete('/remote/path/file.txt');rename(sourcePath: string, destinationPath: string): Promise<string>
Renames or moves a file or directory.
await sftp.rename('/remote/old-name.txt', '/remote/new-name.txt');chmod(path: string, mode: number | string): Promise<string>
Changes file or directory permissions.
await sftp.chmod('/remote/path/file.txt', 0o644);
await sftp.chmod('/remote/path/file.txt', '644');Retry and integrity guidance
An upload method resolves only after the remote file handle closes. A rejected promise can still follow a successful provider commit when the network fails after the server stores the object. Treat that result as unknown, not as proof that the object is absent.
Before you retry an upload:
- Reconnect.
- Call
stat()for the destination. - If the size matches, download the destination and compare its SHA-256 with the local file.
- Accept the first upload when the hash matches. Retry only when the destination is absent or the customer explicitly accepts replacement after a mismatch.
Do not retry every SSH_FX_FAILURE without inspection. The code can mean a generation conflict, an existing no-clobber destination, or an unknown provider result.
Hosted-SFTP rename is no-clobber and non-atomic. An error can leave the source, or it can temporarily leave both names after a provider copy. After a rename error, inspect both paths and compare bytes before you delete either path or retry the move.
Type Definitions
FileInfo
interface FileInfo {
type: FileInfoType; // 'd' | '-' | 'l' (directory, file, symlink)
name: string;
size: number;
modifyTime: number;
accessTime: number;
rights: {
user: string;
group: string;
other: string;
};
owner: number;
group: number;
}FileStats
interface FileStats {
mode: number;
uid: number;
gid: number;
size: number;
accessTime: number;
modifyTime: number;
isDirectory: boolean;
isFile: boolean;
isBlockDevice: boolean;
isCharacterDevice: boolean;
isSymbolicLink: boolean;
isFIFO: boolean;
isSocket: boolean;
}Directory Options
interface DirOptions {
filter?: DirFilterFunction;
}
interface UploadDirOptions extends DirOptions {
useFastput?: boolean; // default true
connections?: number; // default 4
}
interface DownloadDirOptions extends DirOptions {
useFastget?: boolean; // default true
connections?: number; // default 4
}Logging and Tracing
The Temper SFTP Client creates spans for its operations. Spans include:
- Operation type and timing
- File paths
- Host information
- Error details (if any)
- Byte counts for single-file transfers (
get,put,append,downloadFile,uploadFile);downloadDiranduploadDirlog paths only - Full file contents for
get,put, andappend(content), for every SFTP server including Temper-hosted endpoints;getStream,putStream,downloadFile,uploadFile,downloadDir, anduploadDirlog no content
Synced file content is part of the observability product: get, put, and append spans carry the complete file body in sftp.content, the same contract as HTTP request/response body logging. Spans never include the connection password, private key, or passphrase. The file bodies that get, put, and append log are logged as they are, so a file that contains a secret puts that secret in the logs.
Custom Logger
You can provide a custom logger instance:
import { TemperSftpClient } from '@ontemper/sftp';
import { logger } from 'unnbound-logger-sdk';
const sftp = new TemperSftpClient({
host: 'sftp.example.com',
username: 'user',
password: 'pass',
logger: logger, // Custom logger instance
});Example Log Output
{"level":"info","spanId":"556c53e6-3bb8-4118-b3b8-570e41c27654","type":"sftp","sftp":{"host":"sftp.domain.com","operation":"connect"},"message":"SFTP connect started."}
{"level":"info","spanId":"556c53e6-3bb8-4118-b3b8-570e41c27654","type":"sftp","sftp":{"host":"sftp.domain.com","operation":"put","path":"/uploads/file.txt","bytes":15,"content":"A-100,3,shipped"},"duration":150,"message":"SFTP put completed."}Error Handling
All methods return promises that will reject with detailed error information:
try {
await sftp.get('/non-existent-file.txt');
} catch (error) {
console.error('SFTP operation failed:', error.message);
// Error details are automatically logged with the span
}Authentication Methods
Password Authentication
const sftp = new TemperSftpClient({
host: 'sftp.example.com',
username: 'myuser',
password: 'mypassword',
});Private Key Authentication
const sftp = new TemperSftpClient({
host: 'sftp.example.com',
username: 'myuser',
privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----
...your private key content...
-----END OPENSSH PRIVATE KEY-----`,
});Private Key with Passphrase
const sftp = new TemperSftpClient({
host: 'sftp.example.com',
username: 'myuser',
privateKey: 'your-private-key',
passphrase: 'your-passphrase',
});Private Key from Environment Variables
The client automatically normalizes private keys from environment variables by converting \n escape sequences to actual newlines:
// Environment variable: PRIVATE_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END OPENSSH PRIVATE KEY-----"
const sftp = new TemperSftpClient({
host: 'sftp.example.com',
username: 'myuser',
privateKey: process.env.PRIVATE_KEY, // Automatically normalized
});Requirements
- Node.js >= 22.0.0
- TypeScript (for TypeScript projects)
Dependencies
ssh2-sftp-client: Core SFTP functionalityunnbound-logger-sdk: Structured logging and tracing
Support
For support, please open an issue on the GitHub repository.
