sftp-push-sync 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/src/core/SftpPushSyncApp.mjs +6 -0
- package/src/helpers/compare.mjs +87 -46
- package/src/helpers/walkers.mjs +37 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.0] - 2026-03-04
|
|
4
|
+
|
|
5
|
+
- Parallel remote walker walkers.mjs: scans 8 directories simultaneously
|
|
6
|
+
- Batch analysis with concurrency compare.mjs: 8 file comparisons in parallel
|
|
7
|
+
- Parallel hash calculation: local + remote hash simultaneously
|
|
8
|
+
- Keep-alive: SftpPushSyncApp.mjs prevents server disconnection
|
|
9
|
+
|
|
10
|
+
## [2.3.0] - 2026-03-04
|
|
11
|
+
|
|
12
|
+
- Keep-Alive enabled - a Keep-Alive packet is sent every 10 seconds.
|
|
13
|
+
|
|
3
14
|
## [2.1.0] - 2025-11-19
|
|
4
15
|
|
|
5
16
|
Sync only handles files and creates missing directories during upload.
|
package/package.json
CHANGED
|
@@ -736,6 +736,12 @@ export class SftpPushSyncApp {
|
|
|
736
736
|
readyTimeout: 30000, // 30s timeout for initial connection
|
|
737
737
|
});
|
|
738
738
|
connected = true;
|
|
739
|
+
|
|
740
|
+
// Increase max listeners for parallel operations
|
|
741
|
+
if (sftp.client) {
|
|
742
|
+
sftp.client.setMaxListeners(50);
|
|
743
|
+
}
|
|
744
|
+
|
|
739
745
|
this.log(`${TAB_A}${pc.green("✔ Connected to SFTP.")}`);
|
|
740
746
|
|
|
741
747
|
if (!skipSync && !fs.existsSync(this.connection.localRoot)) {
|
package/src/helpers/compare.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import path from "path";
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Analysiert Unterschiede zwischen local- und remote-Maps.
|
|
13
|
+
* Optimiert: Parallelisierte Analyse mit Concurrency-Limit.
|
|
13
14
|
*
|
|
14
15
|
* Erwartete Struktur:
|
|
15
16
|
* local: Map<rel, { rel, localPath, size, mtimeMs, isText? }>
|
|
@@ -21,6 +22,7 @@ import path from "path";
|
|
|
21
22
|
* - getLocalHash / getRemoteHash: from createHashCache
|
|
22
23
|
* - analyzeChunk: Progress-Schrittgröße
|
|
23
24
|
* - updateProgress(prefix, current, total, rel): optional
|
|
25
|
+
* - concurrency: Max parallele Vergleiche (default: 8)
|
|
24
26
|
*/
|
|
25
27
|
export async function analyseDifferences({
|
|
26
28
|
local,
|
|
@@ -31,75 +33,114 @@ export async function analyseDifferences({
|
|
|
31
33
|
getRemoteHash,
|
|
32
34
|
analyzeChunk = 10,
|
|
33
35
|
updateProgress,
|
|
36
|
+
concurrency = 5,
|
|
34
37
|
}) {
|
|
35
38
|
const toAdd = [];
|
|
36
39
|
const toUpdate = [];
|
|
37
40
|
|
|
38
|
-
const localKeys =
|
|
39
|
-
const totalToCheck = localKeys.
|
|
41
|
+
const localKeys = [...local.keys()];
|
|
42
|
+
const totalToCheck = localKeys.length;
|
|
40
43
|
let checked = 0;
|
|
41
44
|
|
|
45
|
+
// Schneller Vorab-Check: Dateien nur lokal → direkt zu toAdd
|
|
46
|
+
const keysToCompare = [];
|
|
42
47
|
for (const rel of localKeys) {
|
|
43
|
-
checked += 1;
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
updateProgress &&
|
|
47
|
-
(checked === 1 || checked % analyzeChunk === 0 || checked === totalToCheck)
|
|
48
|
-
) {
|
|
49
|
-
updateProgress("Analyse: ", checked, totalToCheck, rel);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const l = local.get(rel);
|
|
53
48
|
const r = remote.get(rel);
|
|
54
49
|
const remotePath = path.posix.join(remoteRoot, rel);
|
|
55
|
-
|
|
56
|
-
// Datei existiert nur lokal → New
|
|
50
|
+
|
|
57
51
|
if (!r) {
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
// Datei existiert nur lokal → New (kein SFTP-Call nötig)
|
|
53
|
+
toAdd.push({ rel, local: local.get(rel), remotePath });
|
|
54
|
+
checked++;
|
|
55
|
+
if (updateProgress && checked % analyzeChunk === 0) {
|
|
56
|
+
updateProgress("Analyse: ", checked, totalToCheck, rel);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
keysToCompare.push(rel);
|
|
60
60
|
}
|
|
61
|
+
}
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// Parallele Verarbeitung mit Semaphore
|
|
64
|
+
let activeCount = 0;
|
|
65
|
+
const waiting = [];
|
|
66
|
+
|
|
67
|
+
async function acquireSemaphore() {
|
|
68
|
+
if (activeCount < concurrency) {
|
|
69
|
+
activeCount++;
|
|
70
|
+
return;
|
|
66
71
|
}
|
|
72
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
73
|
+
activeCount++;
|
|
74
|
+
}
|
|
67
75
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
function releaseSemaphore() {
|
|
77
|
+
activeCount--;
|
|
78
|
+
if (waiting.length > 0) {
|
|
79
|
+
const next = waiting.shift();
|
|
80
|
+
next();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
async function compareFile(rel) {
|
|
85
|
+
await acquireSemaphore();
|
|
86
|
+
try {
|
|
87
|
+
const l = local.get(rel);
|
|
88
|
+
const r = remote.get(rel);
|
|
89
|
+
const remotePath = path.posix.join(remoteRoot, rel);
|
|
80
90
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
} else {
|
|
85
|
-
// Binary: Hash-Vergleich mit Cache
|
|
86
|
-
if (!getLocalHash || !getRemoteHash) {
|
|
87
|
-
// Fallback: wenn kein Hash-Cache übergeben wurde, treat as changed
|
|
91
|
+
// 1. Size-Vergleich (schnell, kein SFTP)
|
|
92
|
+
if (l.size !== r.size) {
|
|
88
93
|
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
89
|
-
|
|
94
|
+
return;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// 2. Content-Vergleich
|
|
98
|
+
if (l.isText) {
|
|
99
|
+
// Text-Datei: vollständiger inhaltlicher Vergleich
|
|
100
|
+
const [localBuf, remoteBuf] = await Promise.all([
|
|
101
|
+
fsp.readFile(l.localPath),
|
|
102
|
+
sftp.get(r.remotePath),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
const localStr = localBuf.toString("utf8");
|
|
106
|
+
const remoteStr = (
|
|
107
|
+
Buffer.isBuffer(remoteBuf) ? remoteBuf : Buffer.from(remoteBuf)
|
|
108
|
+
).toString("utf8");
|
|
109
|
+
|
|
110
|
+
if (localStr !== remoteStr) {
|
|
111
|
+
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Binary: Hash-Vergleich mit Cache
|
|
115
|
+
if (!getLocalHash || !getRemoteHash) {
|
|
116
|
+
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const [localHash, remoteHash] = await Promise.all([
|
|
121
|
+
getLocalHash(rel, l),
|
|
122
|
+
getRemoteHash(rel, r, sftp),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
if (localHash !== remoteHash) {
|
|
126
|
+
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
releaseSemaphore();
|
|
131
|
+
checked++;
|
|
132
|
+
if (
|
|
133
|
+
updateProgress &&
|
|
134
|
+
(checked === 1 || checked % analyzeChunk === 0 || checked === totalToCheck)
|
|
135
|
+
) {
|
|
136
|
+
updateProgress("Analyse: ", checked, totalToCheck, rel);
|
|
99
137
|
}
|
|
100
138
|
}
|
|
101
139
|
}
|
|
102
140
|
|
|
141
|
+
// Starte alle Vergleiche parallel (mit Concurrency-Limit durch Semaphore)
|
|
142
|
+
await Promise.all(keysToCompare.map(compareFile));
|
|
143
|
+
|
|
103
144
|
return { toAdd, toUpdate };
|
|
104
145
|
}
|
|
105
146
|
|
package/src/helpers/walkers.mjs
CHANGED
|
@@ -116,6 +116,7 @@ export async function walkLocalPlain(root) {
|
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* Remote-Walker mit INCLUDE/EXCLUDE über filterFn
|
|
119
|
+
* Optimiert: Parallelisierte Verzeichnis-Traversierung
|
|
119
120
|
*/
|
|
120
121
|
export async function walkRemote(
|
|
121
122
|
sftp,
|
|
@@ -125,13 +126,43 @@ export async function walkRemote(
|
|
|
125
126
|
progress = null,
|
|
126
127
|
scanChunk = 100,
|
|
127
128
|
log = null,
|
|
129
|
+
concurrency = 5, // Max parallel directory listings
|
|
128
130
|
} = {}
|
|
129
131
|
) {
|
|
130
132
|
const result = new Map();
|
|
131
133
|
let scanned = 0;
|
|
132
134
|
|
|
135
|
+
// Semaphore für Concurrency-Kontrolle
|
|
136
|
+
let activeCount = 0;
|
|
137
|
+
const waiting = [];
|
|
138
|
+
|
|
139
|
+
async function acquireSemaphore() {
|
|
140
|
+
if (activeCount < concurrency) {
|
|
141
|
+
activeCount++;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
145
|
+
activeCount++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function releaseSemaphore() {
|
|
149
|
+
activeCount--;
|
|
150
|
+
if (waiting.length > 0) {
|
|
151
|
+
const next = waiting.shift();
|
|
152
|
+
next();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
async function recurse(remoteDir, prefix) {
|
|
134
|
-
|
|
157
|
+
await acquireSemaphore();
|
|
158
|
+
let items;
|
|
159
|
+
try {
|
|
160
|
+
items = await sftp.list(remoteDir);
|
|
161
|
+
} finally {
|
|
162
|
+
releaseSemaphore();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const subdirPromises = [];
|
|
135
166
|
|
|
136
167
|
for (const item of items) {
|
|
137
168
|
if (!item.name || item.name === "." || item.name === "..") continue;
|
|
@@ -142,7 +173,8 @@ export async function walkRemote(
|
|
|
142
173
|
if (filterFn && !filterFn(rel)) continue;
|
|
143
174
|
|
|
144
175
|
if (item.type === "d") {
|
|
145
|
-
|
|
176
|
+
// Parallele Verarbeitung von Unterverzeichnissen
|
|
177
|
+
subdirPromises.push(recurse(full, rel));
|
|
146
178
|
} else {
|
|
147
179
|
result.set(rel, {
|
|
148
180
|
rel,
|
|
@@ -166,6 +198,9 @@ export async function walkRemote(
|
|
|
166
198
|
}
|
|
167
199
|
}
|
|
168
200
|
}
|
|
201
|
+
|
|
202
|
+
// Warte auf alle Unterverzeichnisse parallel
|
|
203
|
+
await Promise.all(subdirPromises);
|
|
169
204
|
}
|
|
170
205
|
|
|
171
206
|
await recurse(remoteRoot, "");
|