mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
fix(file-manager): pipeline SFTP download reads (#1485)
This commit is contained in:
1 parent
98cf62547f
commit
0c46950d4c
5 files changed
+404
-1
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import type { Express } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { getMimeType } from "./utils.js";
|
||||
import { createDownloadStream } from "./download-stream.js";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
|
||||
type FileDownloadRoutesDeps = {
|
||||
@@ -296,9 +297,12 @@ export function registerFileDownloadRoutes(
|
||||
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
if (res.destroyed) return;
|
||||
const readStream = createDownloadStream(sftp, filePath, stats.size);
|
||||
res.on("close", () => readStream.destroy());
|
||||
readStream.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.removeHeader("Content-Length");
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Readable } from "node:stream";
|
||||
import type { SFTPWrapper } from "ssh2";
|
||||
import {
|
||||
promisifySftpClose,
|
||||
promisifySftpOpen,
|
||||
SFTP_OPEN_READ,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
const CHUNK_SIZE = 32 * 1024;
|
||||
const CONCURRENCY = 8;
|
||||
|
||||
/** Bounded read-ahead; batches retain file order even if replies arrive out of order. */
|
||||
export function createDownloadStream(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
size: number,
|
||||
): Readable {
|
||||
const stream = Readable.from(readFile(), {
|
||||
objectMode: false,
|
||||
highWaterMark: CHUNK_SIZE,
|
||||
});
|
||||
return stream;
|
||||
|
||||
async function readChunk(handle: Buffer, position: number, length: number) {
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
let offset = 0;
|
||||
while (offset < length && !stream.destroyed) {
|
||||
const bytesRead = await new Promise<number>((resolve, reject) => {
|
||||
sftp.read(
|
||||
handle,
|
||||
buffer,
|
||||
offset,
|
||||
length - offset,
|
||||
position + offset,
|
||||
(err, bytes) => (err ? reject(err) : resolve(bytes)),
|
||||
);
|
||||
});
|
||||
if (bytesRead === 0)
|
||||
throw new Error("File ended before the advertised size");
|
||||
offset += bytesRead;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function* readFile() {
|
||||
if (stream.destroyed) return;
|
||||
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
||||
try {
|
||||
for (let position = 0; position < size && !stream.destroyed;) {
|
||||
const reads: Promise<Buffer>[] = [];
|
||||
for (let i = 0; i < CONCURRENCY && position < size; i++) {
|
||||
const length = Math.min(CHUNK_SIZE, size - position);
|
||||
reads.push(readChunk(handle, position, length));
|
||||
position += length;
|
||||
}
|
||||
// Settle all reads before closing the shared handle after an error.
|
||||
const results = await Promise.allSettled(reads);
|
||||
if (stream.destroyed) return;
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") throw result.reason;
|
||||
yield result.value;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await promisifySftpClose(sftp, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user