@zero-transfer/s3 0.4.7 → 0.4.8

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/dist/index.mjs CHANGED
@@ -1,6 +1,68 @@
1
1
  // src/client/ZeroTransfer.ts
2
2
  import { EventEmitter } from "events";
3
3
 
4
+ // src/logging/redaction.ts
5
+ var REDACTED = "[REDACTED]";
6
+ var SENSITIVE_KEY_PATTERN = /(?:password|passphrase|privatekey|token|secret|username|user)$/i;
7
+ var SECRET_COMMAND_PATTERN = /^(PASS|USER|ACCT)\s+(.+)$/i;
8
+ var URL_KEY_PATTERN = /(?:url|uri|href)$/i;
9
+ function isSensitiveKey(key) {
10
+ return SENSITIVE_KEY_PATTERN.test(key.replace(/[_-]/g, ""));
11
+ }
12
+ function redactCommand(command) {
13
+ return command.replace(SECRET_COMMAND_PATTERN, (_fullMatch, commandName) => {
14
+ return `${commandName.toUpperCase()} ${REDACTED}`;
15
+ });
16
+ }
17
+ function redactValue(value) {
18
+ if (typeof value === "string") {
19
+ return redactCommand(value);
20
+ }
21
+ if (Array.isArray(value)) {
22
+ return value.map((item) => redactValue(item));
23
+ }
24
+ if (value !== null && typeof value === "object") {
25
+ return redactObject(value);
26
+ }
27
+ return value;
28
+ }
29
+ function redactObject(input) {
30
+ return Object.fromEntries(
31
+ Object.entries(input).map(([key, value]) => {
32
+ if (isSensitiveKey(key)) {
33
+ return [key, REDACTED];
34
+ }
35
+ if (URL_KEY_PATTERN.test(key) && typeof value === "string") {
36
+ return [key, redactUrlForLogging(value)];
37
+ }
38
+ return [key, redactValue(value)];
39
+ })
40
+ );
41
+ }
42
+ function redactUrlForLogging(url) {
43
+ let parsed;
44
+ try {
45
+ parsed = typeof url === "string" ? new URL(url) : url;
46
+ } catch {
47
+ return REDACTED;
48
+ }
49
+ const origin = parsed.host.length > 0 ? `${parsed.protocol}//${parsed.host}` : parsed.protocol;
50
+ const query = parsed.search.length > 0 ? `?${REDACTED}` : "";
51
+ return `${origin}${parsed.pathname}${query}`;
52
+ }
53
+ function redactErrorForLogging(error) {
54
+ if (error !== null && typeof error === "object") {
55
+ const candidate = error;
56
+ if (typeof candidate.toJSON === "function") {
57
+ return redactObject(candidate.toJSON());
58
+ }
59
+ }
60
+ if (error instanceof Error) {
61
+ return redactObject({ message: error.message, name: error.name });
62
+ }
63
+ return { message: redactValue(typeof error === "string" ? error : String(error)) };
64
+ }
65
+
4
66
  // src/errors/ZeroTransferError.ts
5
67
  var ZeroTransferError = class extends Error {
6
68
  /** Stable machine-readable error code. */
@@ -42,6 +104,11 @@ var ZeroTransferError = class extends Error {
42
104
  /**
43
105
  * Serializes the error into a plain object suitable for logs or API responses.
44
106
  *
107
+ * `details` and `command` are passed through secret redaction so serialized
108
+ * errors never leak credentials, signed URLs, or raw protocol commands. The
109
+ * live {@link ZeroTransferError.details | details} property stays unredacted
110
+ * for programmatic consumers.
111
+ *
45
112
  * @returns A JSON-safe object containing public structured error fields.
46
113
  */
47
114
  toJSON() {
@@ -51,12 +118,12 @@ var ZeroTransferError = class extends Error {
51
118
  message: this.message,
52
119
  protocol: this.protocol,
53
120
  host: this.host,
54
- command: this.command,
121
+ command: this.command === void 0 ? void 0 : redactCommand(this.command),
55
122
  ftpCode: this.ftpCode,
56
123
  sftpCode: this.sftpCode,
57
124
  path: this.path,
58
125
  retryable: this.retryable,
59
- details: this.details
126
+ details: this.details === void 0 ? void 0 : redactObject(this.details)
60
127
  };
61
128
  }
62
129
  };
@@ -579,15 +646,20 @@ var ProviderRegistry = class {
579
646
  var TransferClient = class {
580
647
  /** Provider registry used by this client. */
581
648
  registry;
649
+ /** Execution defaults applied when call sites omit their own values. */
650
+ defaults;
582
651
  logger;
583
652
  /**
584
653
  * Creates a transfer client without opening any provider connections.
585
654
  *
586
- * @param options - Optional registry, provider factories, and logger.
655
+ * @param options - Optional registry, provider factories, logger, and execution defaults.
587
656
  */
588
657
  constructor(options = {}) {
589
658
  this.registry = options.registry ?? new ProviderRegistry();
590
659
  this.logger = options.logger ?? noopLogger;
660
+ if (options.defaults !== void 0) {
661
+ this.defaults = { ...options.defaults };
662
+ }
591
663
  for (const provider of options.providers ?? []) {
592
664
  this.registry.register(provider);
593
665
  }
@@ -1160,18 +1232,25 @@ var TransferEngine = class {
1160
1232
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
1161
1233
  this.throwIfAborted(abortScope.signal, job);
1162
1234
  const attemptStartedAt = this.now();
1235
+ const attemptScope = createAttemptScope(
1236
+ abortScope.signal,
1237
+ options.timeout,
1238
+ job,
1239
+ attemptNumber
1240
+ );
1163
1241
  const context = this.createExecutionContext(
1164
1242
  job,
1165
1243
  attemptNumber,
1166
1244
  attemptStartedAt,
1167
1245
  options,
1168
- abortScope.signal,
1246
+ attemptScope.signal,
1169
1247
  (bytesTransferred) => {
1170
1248
  latestBytesTransferred = bytesTransferred;
1171
- }
1249
+ },
1250
+ attemptScope.notifyProgress
1172
1251
  );
1173
1252
  try {
1174
- const result = await runExecutor(executor, context, abortScope.signal, job);
1253
+ const result = await runExecutor(executor, context, attemptScope.signal, job);
1175
1254
  context.throwIfAborted();
1176
1255
  latestBytesTransferred = result.bytesTransferred;
1177
1256
  const completedAt = this.now();
@@ -1189,16 +1268,27 @@ var TransferEngine = class {
1189
1268
  summarizeError(error)
1190
1269
  );
1191
1270
  attempts.push(attempt);
1192
- if (error instanceof AbortError || error instanceof TimeoutError) {
1271
+ if (error instanceof AbortError || abortScope.signal?.aborted === true) {
1193
1272
  throw error;
1194
1273
  }
1195
- const retryInput = { attempt: attemptNumber, error, job };
1274
+ const retryInput = {
1275
+ attempt: attemptNumber,
1276
+ elapsedMs: Math.max(0, completedAt.getTime() - startedAt.getTime()),
1277
+ error,
1278
+ job
1279
+ };
1196
1280
  const shouldRetry = attemptNumber < maxAttempts && (options.retry?.shouldRetry?.(retryInput) ?? isRetryable(error));
1197
1281
  if (shouldRetry) {
1198
1282
  options.retry?.onRetry?.(retryInput);
1283
+ const delayMs = normalizeDelayMs(options.retry?.getDelayMs?.(retryInput));
1284
+ if (delayMs > 0) {
1285
+ await sleepWithAbort(delayMs, abortScope.signal, job);
1286
+ }
1199
1287
  continue;
1200
1288
  }
1201
1289
  throw createTransferFailure(job, error, attempts);
1290
+ } finally {
1291
+ attemptScope.dispose();
1202
1292
  }
1203
1293
  }
1204
1294
  throw createTransferFailure(job, void 0, attempts);
@@ -1206,12 +1296,13 @@ var TransferEngine = class {
1206
1296
  abortScope.dispose();
1207
1297
  }
1208
1298
  }
1209
- createExecutionContext(job, attempt, startedAt, options, signal, updateBytesTransferred) {
1299
+ createExecutionContext(job, attempt, startedAt, options, signal, updateBytesTransferred, notifyProgress) {
1210
1300
  const context = {
1211
1301
  attempt,
1212
1302
  job,
1213
1303
  reportProgress: (bytesTransferred, totalBytes) => {
1214
1304
  this.throwIfAborted(signal, job);
1305
+ notifyProgress();
1215
1306
  updateBytesTransferred(bytesTransferred);
1216
1307
  const progressInput = {
1217
1308
  bytesTransferred,
@@ -1280,6 +1371,96 @@ function createAbortScope(parentSignal, timeout, job) {
1280
1371
  signal: controller.signal
1281
1372
  };
1282
1373
  }
1374
+ function createAttemptScope(parentSignal, timeout, job, attempt) {
1375
+ const attemptTimeoutMs = normalizeTimeoutMs(timeout?.attemptTimeoutMs);
1376
+ const stallTimeoutMs = normalizeTimeoutMs(timeout?.stallTimeoutMs);
1377
+ if (attemptTimeoutMs === void 0 && stallTimeoutMs === void 0) {
1378
+ const scope = {
1379
+ dispose: () => void 0,
1380
+ notifyProgress: () => void 0
1381
+ };
1382
+ if (parentSignal !== void 0) scope.signal = parentSignal;
1383
+ return scope;
1384
+ }
1385
+ const controller = new AbortController();
1386
+ const retryable = timeout?.retryable ?? true;
1387
+ const abortFromParent = () => controller.abort(parentSignal?.reason);
1388
+ if (parentSignal?.aborted === true) {
1389
+ abortFromParent();
1390
+ } else {
1391
+ parentSignal?.addEventListener("abort", abortFromParent, { once: true });
1392
+ }
1393
+ const attemptTimer = attemptTimeoutMs === void 0 ? void 0 : setTimeout(() => {
1394
+ controller.abort(
1395
+ new TimeoutError({
1396
+ details: { attempt, attemptTimeoutMs, jobId: job.id, operation: job.operation },
1397
+ message: `Transfer attempt ${String(attempt)} timed out after ${String(attemptTimeoutMs)}ms: ${job.id}`,
1398
+ retryable
1399
+ })
1400
+ );
1401
+ }, attemptTimeoutMs);
1402
+ let stallTimer;
1403
+ const armStallWatchdog = () => {
1404
+ if (stallTimeoutMs === void 0 || controller.signal.aborted) return;
1405
+ if (stallTimer !== void 0) clearTimeout(stallTimer);
1406
+ stallTimer = setTimeout(() => {
1407
+ controller.abort(
1408
+ new TimeoutError({
1409
+ details: { attempt, jobId: job.id, operation: job.operation, stallTimeoutMs },
1410
+ message: `Transfer attempt ${String(attempt)} stalled (no progress for ${String(stallTimeoutMs)}ms): ${job.id}`,
1411
+ retryable
1412
+ })
1413
+ );
1414
+ }, stallTimeoutMs);
1415
+ };
1416
+ armStallWatchdog();
1417
+ return {
1418
+ dispose: () => {
1419
+ if (attemptTimer !== void 0) clearTimeout(attemptTimer);
1420
+ if (stallTimer !== void 0) clearTimeout(stallTimer);
1421
+ parentSignal?.removeEventListener("abort", abortFromParent);
1422
+ },
1423
+ notifyProgress: armStallWatchdog,
1424
+ signal: controller.signal
1425
+ };
1426
+ }
1427
+ function sleepWithAbort(delayMs, signal, job) {
1428
+ return new Promise((resolve, reject) => {
1429
+ if (signal === void 0) {
1430
+ setTimeout(resolve, delayMs);
1431
+ return;
1432
+ }
1433
+ if (signal.aborted) {
1434
+ reject(toAbortFailure(signal, job));
1435
+ return;
1436
+ }
1437
+ const rejectAbort = () => {
1438
+ clearTimeout(timer);
1439
+ reject(toAbortFailure(signal, job));
1440
+ };
1441
+ const timer = setTimeout(() => {
1442
+ signal.removeEventListener("abort", rejectAbort);
1443
+ resolve();
1444
+ }, delayMs);
1445
+ signal.addEventListener("abort", rejectAbort, { once: true });
1446
+ });
1447
+ }
1448
+ function toAbortFailure(signal, job) {
1449
+ if (signal.reason instanceof ZeroTransferError) {
1450
+ return signal.reason;
1451
+ }
1452
+ return new AbortError({
1453
+ details: { jobId: job.id, operation: job.operation },
1454
+ message: `Transfer job aborted: ${job.id}`,
1455
+ retryable: false
1456
+ });
1457
+ }
1458
+ function normalizeDelayMs(value) {
1459
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) {
1460
+ return 0;
1461
+ }
1462
+ return Math.floor(value);
1463
+ }
1283
1464
  function normalizeTimeoutMs(value) {
1284
1465
  if (value === void 0 || !Number.isFinite(value) || value <= 0) {
1285
1466
  return void 0;
@@ -1448,7 +1629,7 @@ async function runRoute(options) {
1448
1629
  const executor = createProviderTransferExecutor({
1449
1630
  resolveSession: ({ role }) => sessions.get(role)
1450
1631
  });
1451
- return await engine.execute(job, executor, buildExecuteOptions(options));
1632
+ return await engine.execute(job, executor, buildExecuteOptions(options, client));
1452
1633
  } finally {
1453
1634
  if (destinationSession !== void 0) {
1454
1635
  await destinationSession.disconnect();
@@ -1485,12 +1666,14 @@ function defaultJobId(route, now) {
1485
1666
  const timestamp = (now?.() ?? /* @__PURE__ */ new Date()).getTime();
1486
1667
  return `route:${route.id}:${timestamp.toString(36)}`;
1487
1668
  }
1488
- function buildExecuteOptions(options) {
1669
+ function buildExecuteOptions(options, client) {
1489
1670
  const execute = {};
1671
+ const retry = options.retry ?? client.defaults?.retry;
1672
+ const timeout = options.timeout ?? client.defaults?.timeout;
1490
1673
  if (options.signal !== void 0) execute.signal = options.signal;
1491
- if (options.retry !== void 0) execute.retry = options.retry;
1674
+ if (retry !== void 0) execute.retry = retry;
1492
1675
  if (options.onProgress !== void 0) execute.onProgress = options.onProgress;
1493
- if (options.timeout !== void 0) execute.timeout = options.timeout;
1676
+ if (timeout !== void 0) execute.timeout = timeout;
1494
1677
  if (options.bandwidthLimit !== void 0) execute.bandwidthLimit = options.bandwidthLimit;
1495
1678
  return execute;
1496
1679
  }
@@ -1547,41 +1730,6 @@ function defaultRouteSuffix(source, destination) {
1547
1730
  return `${source}->${destination}`;
1548
1731
  }
1549
1732
 
1550
- // src/logging/redaction.ts
1551
- var REDACTED = "[REDACTED]";
1552
- var SENSITIVE_KEY_PATTERN = /(?:password|passphrase|privatekey|token|secret|username|user)$/i;
1553
- var SECRET_COMMAND_PATTERN = /^(PASS|USER|ACCT)\s+(.+)$/i;
1554
- function isSensitiveKey(key) {
1555
- return SENSITIVE_KEY_PATTERN.test(key.replace(/[_-]/g, ""));
1556
- }
1557
- function redactCommand(command) {
1558
- return command.replace(SECRET_COMMAND_PATTERN, (_fullMatch, commandName) => {
1559
- return `${commandName.toUpperCase()} ${REDACTED}`;
1560
- });
1561
- }
1562
- function redactValue(value) {
1563
- if (typeof value === "string") {
1564
- return redactCommand(value);
1565
- }
1566
- if (Array.isArray(value)) {
1567
- return value.map((item) => redactValue(item));
1568
- }
1569
- if (value !== null && typeof value === "object") {
1570
- return redactObject(value);
1571
- }
1572
- return value;
1573
- }
1574
- function redactObject(input) {
1575
- return Object.fromEntries(
1576
- Object.entries(input).map(([key, value]) => {
1577
- if (isSensitiveKey(key)) {
1578
- return [key, REDACTED];
1579
- }
1580
- return [key, redactValue(value)];
1581
- })
1582
- );
1583
- }
1584
-
1585
1733
  // src/profiles/SecretSource.ts
1586
1734
  import { Buffer as Buffer3 } from "buffer";
1587
1735
  import { readFile } from "fs/promises";
@@ -1963,11 +2111,11 @@ import {
1963
2111
  import path from "path";
1964
2112
 
1965
2113
  // src/utils/path.ts
1966
- var UNSAFE_FTP_ARGUMENT_PATTERN = /[\r\n]/;
2114
+ var UNSAFE_FTP_ARGUMENT_PATTERN = /[\r\n\0]/;
1967
2115
  function assertSafeFtpArgument(value, label = "path") {
1968
2116
  if (UNSAFE_FTP_ARGUMENT_PATTERN.test(value)) {
1969
2117
  throw new ConfigurationError({
1970
- message: `Unsafe FTP ${label}: CR and LF characters are not allowed`,
2118
+ message: `Unsafe FTP ${label}: CR, LF, and NUL characters are not allowed`,
1971
2119
  retryable: false,
1972
2120
  details: {
1973
2121
  label
@@ -3269,7 +3417,6 @@ function expandAlgorithms(values) {
3269
3417
  }
3270
3418
 
3271
3419
  // src/profiles/importers/FileZillaImporter.ts
3272
- import { Buffer as Buffer6 } from "buffer";
3273
3420
  function importFileZillaSites(xml) {
3274
3421
  const events = tokenizeXml(xml);
3275
3422
  if (events.length === 0) {
@@ -3285,7 +3432,6 @@ function importFileZillaSites(xml) {
3285
3432
  const folderNamePending = [];
3286
3433
  let inServer = false;
3287
3434
  let serverFields = {};
3288
- let serverPasswordEncoding;
3289
3435
  let activeTag;
3290
3436
  let captureFolderName = false;
3291
3437
  for (const event of events) {
@@ -3298,13 +3444,9 @@ function importFileZillaSites(xml) {
3298
3444
  if (event.name === "Server") {
3299
3445
  inServer = true;
3300
3446
  serverFields = {};
3301
- serverPasswordEncoding = void 0;
3302
3447
  continue;
3303
3448
  }
3304
3449
  activeTag = event.name;
3305
- if (event.name === "Pass" && inServer) {
3306
- serverPasswordEncoding = event.attributes["encoding"];
3307
- }
3308
3450
  if (event.name === "Name" && !inServer && folderNamePending.length > 0) {
3309
3451
  captureFolderName = true;
3310
3452
  }
@@ -3330,7 +3472,7 @@ function importFileZillaSites(xml) {
3330
3472
  }
3331
3473
  if (event.name === "Server") {
3332
3474
  const folder = folderStack.filter((segment) => segment !== "");
3333
- const result = buildSiteFromFields(serverFields, serverPasswordEncoding);
3475
+ const result = buildSiteFromFields(serverFields);
3334
3476
  if (result.kind === "site") {
3335
3477
  sites.push({ ...result.site, folder });
3336
3478
  } else {
@@ -3342,7 +3484,6 @@ function importFileZillaSites(xml) {
3342
3484
  }
3343
3485
  inServer = false;
3344
3486
  serverFields = {};
3345
- serverPasswordEncoding = void 0;
3346
3487
  activeTag = void 0;
3347
3488
  continue;
3348
3489
  }
@@ -3351,7 +3492,7 @@ function importFileZillaSites(xml) {
3351
3492
  }
3352
3493
  return { sites, skipped };
3353
3494
  }
3354
- function buildSiteFromFields(fields, passwordEncoding) {
3495
+ function buildSiteFromFields(fields) {
3355
3496
  const name = (fields["Name"] ?? fields["Host"] ?? "Untitled").trim();
3356
3497
  const host = (fields["Host"] ?? "").trim();
3357
3498
  if (host === "") return { kind: "skipped", name };
@@ -3370,18 +3511,9 @@ function buildSiteFromFields(fields, passwordEncoding) {
3370
3511
  }
3371
3512
  const user = fields["User"]?.trim();
3372
3513
  if (user !== void 0 && user !== "") profile.username = { value: user };
3373
- let password;
3374
3514
  const rawPass = fields["Pass"];
3375
- if (rawPass !== void 0 && rawPass !== "") {
3376
- if (passwordEncoding === "base64") {
3377
- password = Buffer6.from(rawPass, "base64").toString("utf8");
3378
- } else {
3379
- password = rawPass;
3380
- }
3381
- if (password !== void 0 && password !== "") profile.password = { value: password };
3382
- }
3383
- const site = { name, profile };
3384
- if (password !== void 0) site.password = password;
3515
+ const hasStoredPassword = rawPass !== void 0 && rawPass !== "";
3516
+ const site = { hasStoredPassword, name, profile };
3385
3517
  const logonText = fields["Logontype"];
3386
3518
  if (logonText !== void 0) {
3387
3519
  const logonType = Number.parseInt(logonText.trim(), 10);
@@ -3624,6 +3756,62 @@ function mapFtp550(details) {
3624
3756
  return new PermissionDeniedError(details);
3625
3757
  }
3626
3758
 
3759
+ // src/transfers/createDefaultRetryPolicy.ts
3760
+ var DEFAULT_MAX_ATTEMPTS = 4;
3761
+ var DEFAULT_BASE_DELAY_MS = 250;
3762
+ var DEFAULT_MAX_DELAY_MS = 3e4;
3763
+ var DEFAULT_MAX_ELAPSED_MS = 3e5;
3764
+ function createDefaultRetryPolicy(options = {}) {
3765
+ const maxAttempts = normalizePositiveInteger(options.maxAttempts, DEFAULT_MAX_ATTEMPTS);
3766
+ const baseDelayMs = normalizeNonNegative(options.baseDelayMs, DEFAULT_BASE_DELAY_MS);
3767
+ const maxDelayMs = normalizeNonNegative(options.maxDelayMs, DEFAULT_MAX_DELAY_MS);
3768
+ const maxElapsedMs = normalizeNonNegative(options.maxElapsedMs, DEFAULT_MAX_ELAPSED_MS);
3769
+ const random = options.random ?? Math.random;
3770
+ return {
3771
+ getDelayMs(input) {
3772
+ const retryAfterMs = readRetryAfterMs(input.error);
3773
+ if (retryAfterMs !== void 0) {
3774
+ return retryAfterMs;
3775
+ }
3776
+ const exponentialMs = baseDelayMs * 2 ** (input.attempt - 1);
3777
+ const cappedMs = Math.min(maxDelayMs, exponentialMs);
3778
+ return Math.floor(random() * cappedMs);
3779
+ },
3780
+ maxAttempts,
3781
+ shouldRetry(input) {
3782
+ if (!(input.error instanceof ZeroTransferError) || !input.error.retryable) {
3783
+ return false;
3784
+ }
3785
+ if (input.elapsedMs >= maxElapsedMs) {
3786
+ return false;
3787
+ }
3788
+ const retryAfterMs = readRetryAfterMs(input.error);
3789
+ if (retryAfterMs !== void 0 && input.elapsedMs + retryAfterMs > maxElapsedMs) {
3790
+ return false;
3791
+ }
3792
+ return true;
3793
+ }
3794
+ };
3795
+ }
3796
+ function readRetryAfterMs(error) {
3797
+ if (!(error instanceof ZeroTransferError)) return void 0;
3798
+ const value = error.details?.["retryAfterMs"];
3799
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
3800
+ return Math.floor(value);
3801
+ }
3802
+ function normalizePositiveInteger(value, fallback) {
3803
+ if (value === void 0 || !Number.isFinite(value) || value < 1) {
3804
+ return fallback;
3805
+ }
3806
+ return Math.floor(value);
3807
+ }
3808
+ function normalizeNonNegative(value, fallback) {
3809
+ if (value === void 0 || !Number.isFinite(value) || value < 0) {
3810
+ return fallback;
3811
+ }
3812
+ return Math.floor(value);
3813
+ }
3814
+
3627
3815
  // src/transfers/TransferPlan.ts
3628
3816
  function createTransferPlan(input) {
3629
3817
  const plan = {
@@ -3721,8 +3909,8 @@ var TransferQueue = class {
3721
3909
  this.concurrency = normalizeConcurrency(options.concurrency);
3722
3910
  this.defaultExecutor = options.executor;
3723
3911
  this.resolveExecutor = options.resolveExecutor;
3724
- this.retry = options.retry;
3725
- this.timeout = options.timeout;
3912
+ this.retry = options.retry ?? options.client?.defaults?.retry;
3913
+ this.timeout = options.timeout ?? options.client?.defaults?.timeout;
3726
3914
  this.bandwidthLimit = options.bandwidthLimit;
3727
3915
  this.onProgress = options.onProgress;
3728
3916
  this.onReceipt = options.onReceipt;
@@ -4811,11 +4999,12 @@ import { join as joinPath } from "path";
4811
4999
 
4812
5000
  // src/providers/web/awsSigv4.ts
4813
5001
  import { createHash, createHmac as createHmac2 } from "crypto";
5002
+ var UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
4814
5003
  function signSigV4(input) {
4815
5004
  const now = input.now ?? /* @__PURE__ */ new Date();
4816
5005
  const amzDate = formatAmzDate(now);
4817
5006
  const dateStamp = amzDate.slice(0, 8);
4818
- const payloadHash = input.body !== void 0 ? sha256Hex(input.body) : sha256Hex(new Uint8Array());
5007
+ const payloadHash = input.unsignedPayload === true ? UNSIGNED_PAYLOAD : input.body !== void 0 ? sha256Hex(input.body) : sha256Hex(new Uint8Array());
4819
5008
  input.headers["host"] = input.url.host;
4820
5009
  input.headers["x-amz-date"] = amzDate;
4821
5010
  input.headers["x-amz-content-sha256"] = payloadHash;
@@ -4890,7 +5079,7 @@ function hmacHex(key, data) {
4890
5079
  }
4891
5080
 
4892
5081
  // src/providers/web/httpInternals.ts
4893
- import { Buffer as Buffer7 } from "buffer";
5082
+ import { Buffer as Buffer6 } from "buffer";
4894
5083
  function parseContentRangeTotal(value) {
4895
5084
  const match = /\/(\d+)\s*$/.exec(value);
4896
5085
  if (match === null) return void 0;
@@ -4916,8 +5105,48 @@ function formatRangeHeader(offset, length) {
4916
5105
  const end = offset + length - 1;
4917
5106
  return `bytes=${String(offset)}-${String(end)}`;
4918
5107
  }
4919
- function mapResponseError(response, path2) {
4920
- const details = { path: path2, status: response.status, statusText: response.statusText };
5108
+ var ERROR_BODY_EXCERPT_LIMIT = 2048;
5109
+ async function readErrorBodyExcerpt(response) {
5110
+ try {
5111
+ const text = await response.text();
5112
+ if (text.length === 0) return void 0;
5113
+ return text.length > ERROR_BODY_EXCERPT_LIMIT ? `${text.slice(0, ERROR_BODY_EXCERPT_LIMIT)}... [truncated]` : text;
5114
+ } catch {
5115
+ return void 0;
5116
+ }
5117
+ }
5118
+ function parseRetryAfterMs(value, now = Date.now) {
5119
+ if (value === null) return void 0;
5120
+ const trimmed = value.trim();
5121
+ if (trimmed.length === 0) return void 0;
5122
+ if (/^\d+$/.test(trimmed)) {
5123
+ const seconds = Number.parseInt(trimmed, 10);
5124
+ return Number.isFinite(seconds) ? seconds * 1e3 : void 0;
5125
+ }
5126
+ if (!/[A-Za-z]/.test(trimmed)) return void 0;
5127
+ const retryAt = Date.parse(trimmed);
5128
+ if (Number.isNaN(retryAt)) return void 0;
5129
+ return Math.max(0, retryAt - now());
5130
+ }
5131
+ async function mapResponseErrorWithBody(response, path2) {
5132
+ return mapResponseError(response, path2, await readErrorBodyExcerpt(response));
5133
+ }
5134
+ function mapResponseError(response, path2, bodyExcerpt) {
5135
+ const details = {
5136
+ path: path2,
5137
+ status: response.status,
5138
+ statusText: response.statusText
5139
+ };
5140
+ if (bodyExcerpt !== void 0) details["body"] = bodyExcerpt;
5141
+ if (response.status === 429 || response.status === 503) {
5142
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
5143
+ if (retryAfterMs !== void 0) details["retryAfterMs"] = retryAfterMs;
5144
+ return new ConnectionError({
5145
+ details,
5146
+ message: response.status === 429 ? `HTTP request for ${path2} was rate limited (429)` : `HTTP service unavailable for ${path2} (503)`,
5147
+ retryable: true
5148
+ });
5149
+ }
4921
5150
  if (response.status === 401) {
4922
5151
  return new AuthenticationError({
4923
5152
  details,
@@ -4949,18 +5178,58 @@ async function* webStreamToAsyncIterable(body) {
4949
5178
  const reader = body.getReader();
4950
5179
  try {
4951
5180
  while (true) {
4952
- const { done, value } = await reader.read();
4953
- if (done) break;
4954
- if (value !== void 0) yield value;
5181
+ let result;
5182
+ try {
5183
+ result = await reader.read();
5184
+ } catch (error) {
5185
+ if (error instanceof ZeroTransferError) throw error;
5186
+ throw new ConnectionError({
5187
+ cause: error,
5188
+ message: "HTTP response stream was interrupted before completion",
5189
+ retryable: true
5190
+ });
5191
+ }
5192
+ if (result.done) break;
5193
+ if (result.value !== void 0) yield result.value;
4955
5194
  }
4956
5195
  } finally {
4957
5196
  reader.releaseLock();
4958
5197
  }
4959
5198
  }
5199
+ function asyncIterableToReadableStream(source, onChunk) {
5200
+ const iterator = source[Symbol.asyncIterator]();
5201
+ return new ReadableStream({
5202
+ async pull(controller) {
5203
+ try {
5204
+ const next = await iterator.next();
5205
+ if (next.done === true) {
5206
+ controller.close();
5207
+ return;
5208
+ }
5209
+ const chunk = next.value;
5210
+ if (chunk.byteLength === 0) {
5211
+ return;
5212
+ }
5213
+ controller.enqueue(chunk);
5214
+ onChunk(chunk);
5215
+ } catch (error) {
5216
+ controller.error(error);
5217
+ }
5218
+ },
5219
+ async cancel(reason) {
5220
+ if (typeof iterator.return === "function") {
5221
+ try {
5222
+ await iterator.return(reason);
5223
+ } catch {
5224
+ }
5225
+ }
5226
+ }
5227
+ });
5228
+ }
4960
5229
  function secretToString(value) {
4961
5230
  if (typeof value === "string") return value;
4962
- if (value instanceof Uint8Array || Buffer7.isBuffer(value)) {
4963
- return Buffer7.from(value).toString("utf8");
5231
+ if (value instanceof Uint8Array || Buffer6.isBuffer(value)) {
5232
+ return Buffer6.from(value).toString("utf8");
4964
5233
  }
4965
5234
  return String(value);
4966
5235
  }
@@ -5172,7 +5441,7 @@ var S3FileSystem = class {
5172
5441
  url.searchParams.set("delimiter", "/");
5173
5442
  if (prefix.length > 0) url.searchParams.set("prefix", prefix);
5174
5443
  const response = await s3Fetch(this.options, "GET", url);
5175
- if (!response.ok) throw mapResponseError(response, normalized);
5444
+ if (!response.ok) throw await mapResponseErrorWithBody(response, normalized);
5176
5445
  const body = await response.text();
5177
5446
  return parseListObjectsV2(body, prefix);
5178
5447
  }
@@ -5180,7 +5449,7 @@ var S3FileSystem = class {
5180
5449
  const normalized = normalizeRemotePath(path2);
5181
5450
  const url = buildObjectUrl(this.options, normalized);
5182
5451
  const response = await s3Fetch(this.options, "HEAD", url);
5183
- if (!response.ok) throw mapResponseError(response, normalized);
5452
+ if (!response.ok) throw await mapResponseErrorWithBody(response, normalized);
5184
5453
  const stat = {
5185
5454
  exists: true,
5186
5455
  name: basenameRemotePath(normalized),
@@ -5220,12 +5489,12 @@ var S3TransferOperations = class {
5220
5489
  extraHeaders: headers
5221
5490
  });
5222
5491
  if (!response.ok && response.status !== 206) {
5223
- throw mapResponseError(response, normalized);
5492
+ throw await mapResponseErrorWithBody(response, normalized);
5224
5493
  }
5225
5494
  const body = response.body;
5226
5495
  if (body === null) {
5227
5496
  throw new ConnectionError({
5228
- message: `S3 response had no body for ${url.toString()}`,
5497
+ message: `S3 response had no body for ${redactUrlForLogging(url)}`,
5229
5498
  retryable: true
5230
5499
  });
5231
5500
  }
@@ -5261,19 +5530,33 @@ var S3TransferOperations = class {
5261
5530
  }
5262
5531
  return this.writeSingleShot(request, normalized);
5263
5532
  }
5533
+ /**
5534
+ * Single PUT upload used when multipart is disabled. Streams the body with
5535
+ * a declared `Content-Length` (signed as `UNSIGNED-PAYLOAD`) when the
5536
+ * caller knows the total size; S3 requires a length up front, so only
5537
+ * unknown-size payloads fall back to buffering the content in memory.
5538
+ */
5264
5539
  async writeSingleShot(request, normalized) {
5265
5540
  const url = buildObjectUrl(this.options, normalized);
5266
- const buffered = await collectChunks(request.content);
5541
+ const totalBytes = request.totalBytes;
5542
+ if (typeof totalBytes !== "number" || totalBytes < 0) {
5543
+ const buffered = await collectChunks(request.content);
5544
+ return this.singleShotFromBuffer(request, normalized, buffered);
5545
+ }
5546
+ let bytesTransferred = 0;
5547
+ const stream = asyncIterableToReadableStream(request.content, (chunk) => {
5548
+ bytesTransferred += chunk.byteLength;
5549
+ request.reportProgress(bytesTransferred, totalBytes);
5550
+ });
5267
5551
  const response = await s3Fetch(this.options, "PUT", url, {
5268
5552
  ...request.signal !== void 0 ? { signal: request.signal } : {},
5269
- body: buffered,
5270
- extraHeaders: { "content-type": "application/octet-stream" }
5553
+ extraHeaders: { "content-type": "application/octet-stream" },
5554
+ streamBody: { content: stream, contentLength: totalBytes }
5271
5555
  });
5272
- if (!response.ok) throw mapResponseError(response, normalized);
5273
- request.reportProgress(buffered.byteLength, buffered.byteLength);
5556
+ if (!response.ok) throw await mapResponseErrorWithBody(response, normalized);
5274
5557
  const result = {
5275
- bytesTransferred: buffered.byteLength,
5276
- totalBytes: buffered.byteLength
5558
+ bytesTransferred,
5559
+ totalBytes
5277
5560
  };
5278
5561
  const etag = response.headers.get("etag");
5279
5562
  if (etag !== null) result.checksum = etag;
@@ -5337,7 +5620,7 @@ var S3TransferOperations = class {
5337
5620
  ...request.signal !== void 0 ? { signal: request.signal } : {},
5338
5621
  extraHeaders: { "content-type": "application/octet-stream" }
5339
5622
  });
5340
- if (!initiateResponse.ok) throw mapResponseError(initiateResponse, normalized);
5623
+ if (!initiateResponse.ok) throw await mapResponseErrorWithBody(initiateResponse, normalized);
5341
5624
  const initiateBody = await initiateResponse.text();
5342
5625
  const initiated = innerText(initiateBody, "UploadId");
5343
5626
  if (initiated === void 0 || initiated === "") {
@@ -5376,7 +5659,7 @@ var S3TransferOperations = class {
5376
5659
  body: partBytes.bytes
5377
5660
  });
5378
5661
  if (!partResponse.ok) {
5379
- throw mapResponseError(partResponse, normalized);
5662
+ throw await mapResponseErrorWithBody(partResponse, normalized);
5380
5663
  }
5381
5664
  const partEtag = partResponse.headers.get("etag");
5382
5665
  if (partEtag === null) {
@@ -5432,7 +5715,7 @@ var S3TransferOperations = class {
5432
5715
  if (resumeStore === void 0) {
5433
5716
  await abortMultipart(this.options, objectUrl, uploadId).catch(() => void 0);
5434
5717
  }
5435
- throw mapResponseError(completeResponse, normalized);
5718
+ throw await mapResponseErrorWithBody(completeResponse, normalized);
5436
5719
  }
5437
5720
  if (resumeStore !== void 0) await resumeStore.clear(resumeKey);
5438
5721
  const completeBody = await completeResponse.text();
@@ -5451,7 +5734,7 @@ var S3TransferOperations = class {
5451
5734
  body: buffered,
5452
5735
  extraHeaders: { "content-type": "application/octet-stream" }
5453
5736
  });
5454
- if (!response.ok) throw mapResponseError(response, normalized);
5737
+ if (!response.ok) throw await mapResponseErrorWithBody(response, normalized);
5455
5738
  request.reportProgress(buffered.byteLength, buffered.byteLength);
5456
5739
  const result = {
5457
5740
  bytesTransferred: buffered.byteLength,
@@ -5469,6 +5752,8 @@ async function s3Fetch(options, method, url, fetchOptions = {}) {
5469
5752
  };
5470
5753
  if (fetchOptions.body !== void 0) {
5471
5754
  headers["content-length"] = String(fetchOptions.body.byteLength);
5755
+ } else if (fetchOptions.streamBody !== void 0) {
5756
+ headers["content-length"] = String(fetchOptions.streamBody.contentLength);
5472
5757
  }
5473
5758
  signSigV4({
5474
5759
  accessKeyId: options.accessKeyId,
@@ -5479,10 +5764,16 @@ async function s3Fetch(options, method, url, fetchOptions = {}) {
5479
5764
  service: options.service,
5480
5765
  url,
5481
5766
  ...fetchOptions.body !== void 0 ? { body: fetchOptions.body } : {},
5767
+ ...fetchOptions.streamBody !== void 0 ? { unsignedPayload: true } : {},
5482
5768
  ...options.sessionToken !== void 0 ? { sessionToken: options.sessionToken } : {}
5483
5769
  });
5484
5770
  const init = { headers, method };
5485
- if (fetchOptions.body !== void 0) init.body = fetchOptions.body;
5771
+ if (fetchOptions.body !== void 0) {
5772
+ init.body = fetchOptions.body;
5773
+ } else if (fetchOptions.streamBody !== void 0) {
5774
+ init.body = fetchOptions.streamBody.content;
5775
+ init.duplex = "half";
5776
+ }
5486
5777
  if (fetchOptions.signal !== void 0) init.signal = fetchOptions.signal;
5487
5778
  const controller = new AbortController();
5488
5779
  const upstreamSignal = init.signal ?? null;
@@ -5500,10 +5791,11 @@ async function s3Fetch(options, method, url, fetchOptions = {}) {
5500
5791
  try {
5501
5792
  return await options.fetch(url.toString(), { ...init, signal: controller.signal });
5502
5793
  } catch (error) {
5794
+ const safeUrl = redactUrlForLogging(url);
5503
5795
  throw new ConnectionError({
5504
5796
  cause: error,
5505
- details: { url: url.toString() },
5506
- message: `S3 request to ${url.toString()} failed`,
5797
+ details: { url: safeUrl },
5798
+ message: `S3 request to ${safeUrl} failed`,
5507
5799
  retryable: true
5508
5800
  });
5509
5801
  } finally {
@@ -5675,6 +5967,7 @@ export {
5675
5967
  copyBetween,
5676
5968
  createAtomicDeployPlan,
5677
5969
  createBandwidthThrottle,
5970
+ createDefaultRetryPolicy,
5678
5971
  createFileSystemS3MultipartResumeStore,
5679
5972
  createLocalProviderFactory,
5680
5973
  createMemoryProviderFactory,
@@ -5713,8 +6006,10 @@ export {
5713
6006
  parseRemoteManifest,
5714
6007
  redactCommand,
5715
6008
  redactConnectionProfile,
6009
+ redactErrorForLogging,
5716
6010
  redactObject,
5717
6011
  redactSecretSource,
6012
+ redactUrlForLogging,
5718
6013
  redactValue,
5719
6014
  resolveConnectionProfileSecrets,
5720
6015
  resolveOpenSshHost,