sftp-push-sync 2.1.5 → 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/README.md +3 -2
- package/package.json +1 -1
- package/src/core/SftpPushSyncApp.mjs +11 -1
- 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/README.md
CHANGED
|
@@ -157,7 +157,7 @@ There are 7 steps to follow:
|
|
|
157
157
|
|
|
158
158
|
- Phase 1: Scan local files
|
|
159
159
|
- Phase 2: Scan remote files
|
|
160
|
-
- Phase 3: Compare &
|
|
160
|
+
- Phase 3: Compare & Decide
|
|
161
161
|
- Phase 4: Removing orphaned remote files
|
|
162
162
|
- Phase 5: Preparing remote directories
|
|
163
163
|
- Phase 6: Apply changes
|
|
@@ -258,7 +258,8 @@ However, it should also manage directories:
|
|
|
258
258
|
|
|
259
259
|
You can safely delete the local cache at any time. The first analysis will then take longer, because remote hashes will be streamed again. After that, everything will run fast.
|
|
260
260
|
|
|
261
|
-
Note: The first run always takes a while, especially with lots of media – so be patient! Once the cache is full, it will be faster.
|
|
261
|
+
Note 1: The first run always takes a while, especially with lots of media – so be patient! Once the cache is full, it will be faster.
|
|
262
|
+
Note 2: Reliability and accuracy are more important to me than speed.
|
|
262
263
|
|
|
263
264
|
## Example Output
|
|
264
265
|
|
package/package.json
CHANGED
|
@@ -730,8 +730,18 @@ export class SftpPushSyncApp {
|
|
|
730
730
|
port: this.connection.port,
|
|
731
731
|
username: this.connection.user,
|
|
732
732
|
password: this.connection.password,
|
|
733
|
+
// Keep-Alive to prevent server disconnection during long operations
|
|
734
|
+
keepaliveInterval: 10000, // Send keepalive every 10 seconds
|
|
735
|
+
keepaliveCountMax: 10, // Allow up to 10 missed keepalives before disconnect
|
|
736
|
+
readyTimeout: 30000, // 30s timeout for initial connection
|
|
733
737
|
});
|
|
734
738
|
connected = true;
|
|
739
|
+
|
|
740
|
+
// Increase max listeners for parallel operations
|
|
741
|
+
if (sftp.client) {
|
|
742
|
+
sftp.client.setMaxListeners(50);
|
|
743
|
+
}
|
|
744
|
+
|
|
735
745
|
this.log(`${TAB_A}${pc.green("✔ Connected to SFTP.")}`);
|
|
736
746
|
|
|
737
747
|
if (!skipSync && !fs.existsSync(this.connection.localRoot)) {
|
|
@@ -838,7 +848,7 @@ export class SftpPushSyncApp {
|
|
|
838
848
|
this.log("");
|
|
839
849
|
|
|
840
850
|
// Phase 3 – Analyse Differences (delegiert an Helper)
|
|
841
|
-
this.log(pc.bold(pc.cyan("🔎 Phase 3: Compare &
|
|
851
|
+
this.log(pc.bold(pc.cyan("🔎 Phase 3: Compare & Decide …")));
|
|
842
852
|
|
|
843
853
|
const { getLocalHash, getRemoteHash, save: saveCache } = this.hashCache;
|
|
844
854
|
|
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, "");
|