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 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 & decide
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sftp-push-sync",
3
- "version": "2.1.5",
3
+ "version": "2.5.0",
4
4
  "description": "SFTP sync tool for Hugo projects (local to remote, with hash cache)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 & decide …")));
851
+ this.log(pc.bold(pc.cyan("🔎 Phase 3: Compare & Decide …")));
842
852
 
843
853
  const { getLocalHash, getRemoteHash, save: saveCache } = this.hashCache;
844
854
 
@@ -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 = new Set(local.keys());
39
- const totalToCheck = localKeys.size;
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
- toAdd.push({ rel, local: l, remotePath });
59
- continue;
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
- // 1. Size-Vergleich
63
- if (l.size !== r.size) {
64
- toUpdate.push({ rel, local: l, remote: r, remotePath });
65
- continue;
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
- // 2. Content-Vergleich
69
- if (l.isText) {
70
- // Text-Datei: vollständiger inhaltlicher Vergleich
71
- const [localBuf, remoteBuf] = await Promise.all([
72
- fsp.readFile(l.localPath),
73
- sftp.get(r.remotePath),
74
- ]);
76
+ function releaseSemaphore() {
77
+ activeCount--;
78
+ if (waiting.length > 0) {
79
+ const next = waiting.shift();
80
+ next();
81
+ }
82
+ }
75
83
 
76
- const localStr = localBuf.toString("utf8");
77
- const remoteStr = (
78
- Buffer.isBuffer(remoteBuf) ? remoteBuf : Buffer.from(remoteBuf)
79
- ).toString("utf8");
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
- if (localStr !== remoteStr) {
82
- toUpdate.push({ rel, local: l, remote: r, remotePath });
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
- continue;
94
+ return;
90
95
  }
91
96
 
92
- const [localHash, remoteHash] = await Promise.all([
93
- getLocalHash(rel, l),
94
- getRemoteHash(rel, r, sftp),
95
- ]);
96
-
97
- if (localHash !== remoteHash) {
98
- toUpdate.push({ rel, local: l, remote: r, remotePath });
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
 
@@ -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
- const items = await sftp.list(remoteDir);
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
- await recurse(full, rel);
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, "");