nukebase 2.5.1 → 2.5.2
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/lib/project-commands.js +74 -18
- package/lib/storage.js +40 -11
- package/package.json +1 -1
package/lib/project-commands.js
CHANGED
|
@@ -177,34 +177,83 @@ function splitBySize(files, sizeOf, n) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// One progress line shared by every stream of a transfer. counter() gives each
|
|
180
|
-
// stream its own pass-through that adds to the shared total
|
|
180
|
+
// stream its own pass-through that adds to the shared total; add(n) counts
|
|
181
|
+
// bytes moved without a stream (storage uploads; negative takes back a failed
|
|
182
|
+
// attempt).
|
|
183
|
+
//
|
|
184
|
+
// The speed is the rate over the last few seconds, not the average since the
|
|
185
|
+
// start, and it is redrawn on a timer rather than when bytes arrive:
|
|
186
|
+
// - An upload's bytes are counted as they enter the request. The first
|
|
187
|
+
// several MB land in Node's and the OS's send buffers almost at once
|
|
188
|
+
// (measured ~10 MB on Windows), so a whole-run average opened far above
|
|
189
|
+
// the line speed and took a long time to fall. The window starts only once
|
|
190
|
+
// the buffers have filled (SETTLE_MS after the first byte); from then on
|
|
191
|
+
// the counted bytes move at the real pace.
|
|
192
|
+
// - Redrawing only on data froze the line during a stall, then jumped.
|
|
193
|
+
// Live redraws go to a terminal only (in a log or an AI agent they pile up);
|
|
194
|
+
// the final line, with the whole-run average, is always printed.
|
|
181
195
|
function progressMeter(totalBytes) {
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
const TICK_MS = 250; // redraw interval
|
|
197
|
+
const SETTLE_MS = 1000; // after the first byte: buffers filling, not measured
|
|
198
|
+
const WINDOW_MS = 5000; // speed = bytes over (at most) this long
|
|
199
|
+
// Shortest span worth showing a speed for. Bytes leave in steps of a few MB
|
|
200
|
+
// (the OS frees send buffer in bursts), so a shorter span can catch a gap
|
|
201
|
+
// between steps and read 0.
|
|
202
|
+
const MIN_SPAN_MS = 2000;
|
|
203
|
+
const SMOOTH = 0.3; // per-tick weight of the newest reading
|
|
204
|
+
const tty = !!process.stdout.isTTY;
|
|
184
205
|
const start = Date.now();
|
|
206
|
+
let bytes = 0;
|
|
207
|
+
let firstAt = 0;
|
|
208
|
+
let speed = null; // bytes per second, smoothed; null until measured
|
|
209
|
+
const samples = []; // [time, bytes], from SETTLE_MS after the first byte
|
|
185
210
|
const mb = (n) => (n / 1024 / 1024).toFixed(2);
|
|
186
|
-
|
|
211
|
+
|
|
212
|
+
const measure = () => {
|
|
187
213
|
const now = Date.now();
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
if (bytes > 0 && !firstAt) firstAt = now;
|
|
215
|
+
if (!firstAt || now - firstAt < SETTLE_MS) return;
|
|
216
|
+
// All of an upload counted: the tail is still in the send buffers, going
|
|
217
|
+
// out at the same pace. Hold the speed rather than let it decay to 0.
|
|
218
|
+
if (totalBytes > 0 && bytes >= totalBytes) return;
|
|
219
|
+
samples.push([now, bytes]);
|
|
220
|
+
while (samples.length > 2 && now - samples[1][0] >= WINDOW_MS) samples.shift();
|
|
221
|
+
const [t0, b0] = samples[0];
|
|
222
|
+
if (now - t0 < MIN_SPAN_MS) return;
|
|
223
|
+
const rate = Math.max(0, (bytes - b0) / ((now - t0) / 1000));
|
|
224
|
+
speed = speed === null ? rate : speed + SMOOTH * (rate - speed);
|
|
225
|
+
};
|
|
226
|
+
const line = () => {
|
|
227
|
+
const shown = totalBytes > 0 ? Math.min(bytes, totalBytes) : bytes;
|
|
194
228
|
const of = totalBytes > 0 ? ` of ${mb(totalBytes).padStart(7)}` : '';
|
|
195
|
-
|
|
229
|
+
const rate = speed === null ? ' -- MB/s' : `${(speed / 1024 / 1024).toFixed(2).padStart(6)} MB/s (${Math.round(speed * 8 / 1e6)} Mbps)`;
|
|
230
|
+
const remaining = Math.max(0, totalBytes - bytes);
|
|
231
|
+
const eta = totalBytes > 0 && remaining === 0 ? 'finishing'
|
|
232
|
+
: speed > 0 && totalBytes > 0 ? formatEta(remaining / speed) : '--:--';
|
|
233
|
+
return `\r ${mb(shown).padStart(7)}${of} MB || ${rate} || ETA ${String(eta).padStart(5)} `;
|
|
196
234
|
};
|
|
235
|
+
const tick = () => { measure(); if (tty) process.stdout.write(line()); };
|
|
236
|
+
const timer = setInterval(tick, TICK_MS);
|
|
237
|
+
if (timer.unref) timer.unref();
|
|
238
|
+
|
|
197
239
|
return {
|
|
198
240
|
counter() {
|
|
199
|
-
return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length;
|
|
241
|
+
return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; cb(null, chunk); } });
|
|
200
242
|
},
|
|
201
|
-
|
|
202
|
-
add(n) { bytes += n; draw(false); },
|
|
243
|
+
add(n) { bytes = Math.max(0, bytes + n); },
|
|
203
244
|
bytes: () => bytes,
|
|
245
|
+
// For a transfer that failed: stop redrawing and end the line, so the
|
|
246
|
+
// error printed next is not drawn over. done() is for one that finished.
|
|
247
|
+
stop() {
|
|
248
|
+
clearInterval(timer);
|
|
249
|
+
if (tty && firstAt) process.stdout.write('\n');
|
|
250
|
+
},
|
|
204
251
|
done() {
|
|
205
|
-
|
|
252
|
+
clearInterval(timer);
|
|
253
|
+
if (tty) process.stdout.write(line());
|
|
206
254
|
const secs = Math.max((Date.now() - start) / 1000, 0.001);
|
|
207
|
-
|
|
255
|
+
const avg = bytes / secs;
|
|
256
|
+
console.log(`${tty ? '\n' : ''} ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(avg / 1024 / 1024).toFixed(2)} MB/s, ${Math.round(avg * 8 / 1e6)} Mbps)`);
|
|
208
257
|
}
|
|
209
258
|
};
|
|
210
259
|
}
|
|
@@ -283,6 +332,9 @@ async function downloadStreams(dest, files, sizeOf, totalBytes) {
|
|
|
283
332
|
if (!stopRequested) throw err;
|
|
284
333
|
}
|
|
285
334
|
}));
|
|
335
|
+
} catch (err) {
|
|
336
|
+
meter.stop();
|
|
337
|
+
throw err;
|
|
286
338
|
} finally {
|
|
287
339
|
process.off('SIGINT', onSigint);
|
|
288
340
|
}
|
|
@@ -1670,7 +1722,9 @@ async function push() {
|
|
|
1670
1722
|
if (storageUp.length) {
|
|
1671
1723
|
console.log(`\n File storage (${storageUp.length} file(s)):`);
|
|
1672
1724
|
const meter = progressMeter(storageBytes);
|
|
1673
|
-
|
|
1725
|
+
try {
|
|
1726
|
+
storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
|
|
1727
|
+
} catch (e) { meter.stop(); throw e; }
|
|
1674
1728
|
meter.done();
|
|
1675
1729
|
if (storageFailed.length) {
|
|
1676
1730
|
console.error(`\n❌ ${storageFailed.length} file(s) did not reach the storage; push again to send them:`);
|
|
@@ -2436,7 +2490,9 @@ async function backup() {
|
|
|
2436
2490
|
if (storageChanged.length) {
|
|
2437
2491
|
console.log(`\n File storage (${storageChanged.length} file(s)):`);
|
|
2438
2492
|
const meter = progressMeter(storageBytes);
|
|
2439
|
-
|
|
2493
|
+
try {
|
|
2494
|
+
storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
|
|
2495
|
+
} catch (e) { meter.stop(); throw e; }
|
|
2440
2496
|
meter.done();
|
|
2441
2497
|
}
|
|
2442
2498
|
|
package/lib/storage.js
CHANGED
|
@@ -50,8 +50,12 @@ async function manifest(API, authHeaders) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// One request to the bucket, IPv4, failing on IDLE_MS without progress.
|
|
53
|
-
// Resolves { status, headers, stream } once headers arrive.
|
|
54
|
-
|
|
53
|
+
// Resolves { status, headers, stream } once headers arrive. With onSent, the
|
|
54
|
+
// body goes out in SLICE pieces and onSent(n) is called as each one is handed
|
|
55
|
+
// to the network, so the progress line moves while a part uploads instead of
|
|
56
|
+
// jumping by a whole part (up to 16 MB) when it finishes.
|
|
57
|
+
const SLICE = 256 * 1024;
|
|
58
|
+
function request(method, url, body, onSent) {
|
|
55
59
|
return new Promise((resolve, reject) => {
|
|
56
60
|
const u = new URL(url);
|
|
57
61
|
const mod = u.protocol === 'http:' ? http : https;
|
|
@@ -64,10 +68,37 @@ function request(method, url, body) {
|
|
|
64
68
|
});
|
|
65
69
|
req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
|
|
66
70
|
req.on('error', reject);
|
|
67
|
-
req.end(body || undefined);
|
|
71
|
+
if (!body || !onSent) { req.end(body || undefined); return; }
|
|
72
|
+
let off = 0;
|
|
73
|
+
const writeMore = () => {
|
|
74
|
+
while (off < body.length) {
|
|
75
|
+
const end = Math.min(off + SLICE, body.length);
|
|
76
|
+
const n = end - off;
|
|
77
|
+
const more = req.write(body.subarray(off, end), (err) => { if (!err) onSent(n); });
|
|
78
|
+
off = end;
|
|
79
|
+
if (!more) { req.once('drain', writeMore); return; }
|
|
80
|
+
}
|
|
81
|
+
req.end();
|
|
82
|
+
};
|
|
83
|
+
writeMore();
|
|
68
84
|
});
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
// One upload attempt whose bytes are reported as they go out. If the attempt
|
|
88
|
+
// fails, what it reported is taken back, so a retried part is not counted
|
|
89
|
+
// twice and the line cannot pass the total.
|
|
90
|
+
async function counted(onBytes, attempt) {
|
|
91
|
+
let n = 0;
|
|
92
|
+
let over = false; // a write callback landing after the failure adds nothing
|
|
93
|
+
try {
|
|
94
|
+
return await attempt((k) => { if (over) return; n += k; onBytes(k); });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
over = true;
|
|
97
|
+
if (n) onBytes(-n);
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
async function retry(what, fn) {
|
|
72
103
|
let last;
|
|
73
104
|
for (let i = 0; i < TRIES; i++) {
|
|
@@ -214,12 +245,11 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
214
245
|
const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
|
|
215
246
|
if (plan.url) {
|
|
216
247
|
const body = fs.readFileSync(file);
|
|
217
|
-
await retry(key, async () => {
|
|
218
|
-
const r = await request('PUT', plan.url, body);
|
|
248
|
+
await retry(key, () => counted(onBytes, async (onSent) => {
|
|
249
|
+
const r = await request('PUT', plan.url, body, onSent);
|
|
219
250
|
r.stream.resume();
|
|
220
251
|
if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
|
|
221
|
-
});
|
|
222
|
-
onBytes(size);
|
|
252
|
+
}));
|
|
223
253
|
sent.push(key);
|
|
224
254
|
return;
|
|
225
255
|
}
|
|
@@ -230,13 +260,12 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
230
260
|
const len = Math.min(plan.partSize, size - i * plan.partSize);
|
|
231
261
|
const buf = Buffer.alloc(len);
|
|
232
262
|
fs.readSync(fd, buf, 0, len, i * plan.partSize);
|
|
233
|
-
await retry(`${key} part ${i + 1}`, async () => {
|
|
234
|
-
const r = await request('PUT', url, buf);
|
|
263
|
+
await retry(`${key} part ${i + 1}`, () => counted(onBytes, async (onSent) => {
|
|
264
|
+
const r = await request('PUT', url, buf, onSent);
|
|
235
265
|
r.stream.resume();
|
|
236
266
|
if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
|
|
237
267
|
etags[i] = r.headers.etag;
|
|
238
|
-
});
|
|
239
|
-
onBytes(len);
|
|
268
|
+
}));
|
|
240
269
|
});
|
|
241
270
|
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
|
|
242
271
|
sent.push(key);
|