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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sftp-push-sync",
3
- "version": "2.3.0",
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": {
@@ -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)) {
@@ -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, "");