@cloudbitmaps/s3 0.19.1 → 0.21.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/dist/index.js CHANGED
@@ -1,7 +1,17 @@
1
1
  // src/backend.ts
2
- import { ValidationError as ValidationError5, brandAsBackend } from "@cloudbitmaps/core/driver-kit";
2
+ import {
3
+ ValidationError as ValidationError5,
4
+ brandAsBackend,
5
+ refuseUnknownOptions,
6
+ requirePositiveInt as requirePositiveInt2
7
+ } from "@cloudbitmaps/core/driver-kit";
3
8
  import { S3Client } from "@aws-sdk/client-s3";
4
9
 
10
+ // src/defined.ts
11
+ function defined(values) {
12
+ return Object.fromEntries(Object.entries(values).filter(([, v]) => v !== void 0));
13
+ }
14
+
5
15
  // src/storage.ts
6
16
  import {
7
17
  IntegrityError,
@@ -9,61 +19,42 @@ import {
9
19
  TransientError as TransientError2,
10
20
  ValidationError as ValidationError3,
11
21
  WriteConflictError,
22
+ concatBytes,
12
23
  isNotFoundError,
13
24
  isValidationError,
14
- isWriteConflictError
25
+ isWriteConflictError,
26
+ normalizeObjectPrefix,
27
+ parseGenerationFromKey,
28
+ requireBool,
29
+ requirePositiveInt,
30
+ segmentObjectPrefix,
31
+ storageObjectKey,
32
+ toTransient
15
33
  } from "@cloudbitmaps/core/driver-kit";
16
34
  import { createHash, randomBytes } from "node:crypto";
17
35
  import {
18
36
  AbortMultipartUploadCommand,
19
37
  CompleteMultipartUploadCommand,
20
38
  CreateMultipartUploadCommand,
21
- DeleteObjectCommand,
39
+ DeleteObjectCommand as DeleteObjectCommand2,
22
40
  GetObjectCommand,
23
41
  HeadObjectCommand,
24
42
  ListObjectsV2Command,
25
- PutObjectCommand,
43
+ PutObjectCommand as PutObjectCommand2,
26
44
  UploadPartCommand
27
45
  } from "@aws-sdk/client-s3";
28
46
 
29
- // src/keys.ts
30
- import {
31
- ValidationError,
32
- encodeNameForKey,
33
- namespaceKeyPart,
34
- prefixPart,
35
- validateSegmentRef
36
- } from "@cloudbitmaps/core/driver-kit";
37
- import { normalizeObjectPrefix } from "@cloudbitmaps/core/driver-kit";
38
- var SUFFIX = ".crbm";
39
- function segmentObjectPrefix(prefix, ref) {
40
- validateSegmentRef(ref);
41
- return `${prefixPart(prefix)}${namespaceKeyPart(ref.namespace)}/segments/${encodeNameForKey(ref.segment)}.`;
42
- }
43
- function storageObjectKey(prefix, key) {
44
- if (!Number.isInteger(key.generation) || key.generation < 0) {
45
- throw new ValidationError(`generation must be a non-negative integer; got ${key.generation}`);
46
- }
47
- return `${segmentObjectPrefix(prefix, key)}${key.generation}${SUFFIX}`;
48
- }
49
- function parseGenerationFromKey(segmentPrefix, objectKey) {
50
- if (!objectKey.startsWith(segmentPrefix) || !objectKey.endsWith(SUFFIX)) return null;
51
- const middle = objectKey.slice(segmentPrefix.length, objectKey.length - SUFFIX.length);
52
- if (!/^(0|[1-9]\d*)$/.test(middle)) return null;
53
- const generation = Number(middle);
54
- return Number.isSafeInteger(generation) ? generation : null;
55
- }
47
+ // src/s3-errors.ts
48
+ import { ValidationError } from "@cloudbitmaps/core/driver-kit";
56
49
 
57
50
  // src/aws-errors.ts
51
+ import { isTransportFault } from "@cloudbitmaps/core/driver-kit";
58
52
  function httpStatus(err) {
59
53
  return err?.$metadata?.httpStatusCode;
60
54
  }
61
55
  function errorName(err) {
62
56
  return err?.name;
63
57
  }
64
- function errorCode(err) {
65
- return err?.code;
66
- }
67
58
  function isSdkRetryable(err) {
68
59
  return err?.$retryable != null;
69
60
  }
@@ -78,19 +69,10 @@ var NETWORK_NAMES = /* @__PURE__ */ new Set([
78
69
  "NetworkingError",
79
70
  "AbortError"
80
71
  ]);
81
- var NETWORK_CODES = /* @__PURE__ */ new Set([
82
- "ETIMEDOUT",
83
- "ECONNRESET",
84
- "ECONNREFUSED",
85
- "EPIPE",
86
- "ENOTFOUND",
87
- "EAI_AGAIN",
88
- "ECONNABORTED"
89
- ]);
90
72
  var NETWORK_MESSAGE = /socket hang up|network (error|failure)|(connection|request|socket|operation|read|write)\s+tim(e|ed)\s?out|connection (reset|refused|aborted|closed)/i;
91
73
  function isNetworkOrTimeout(err) {
92
74
  if (NETWORK_NAMES.has(errorName(err) ?? "")) return true;
93
- if (NETWORK_CODES.has(errorCode(err) ?? "")) return true;
75
+ if (isTransportFault(err)) return true;
94
76
  return NETWORK_MESSAGE.test(err?.message ?? "");
95
77
  }
96
78
 
@@ -110,6 +92,15 @@ function isNoSuchUpload(err) {
110
92
  function isMissingBucket(err) {
111
93
  return errorName(err) === "NoSuchBucket";
112
94
  }
95
+ function asMissingBucket(err, bucket) {
96
+ if (!isMissingBucket(err)) return err;
97
+ return Object.assign(
98
+ new ValidationError(
99
+ `the S3 bucket does not exist: ${bucket}; check the bucket name, and the region and endpoint of the client`
100
+ ),
101
+ { cause: err }
102
+ );
103
+ }
113
104
  function isNotFound(err) {
114
105
  if (isMissingBucket(err)) return false;
115
106
  const name = errorName(err);
@@ -126,6 +117,9 @@ function isTransient(err) {
126
117
  if (isConditionalConflict(err) || isNotFound(err) || isInvalidRange(err)) return false;
127
118
  return errorName(err) === "SlowDown" || isServerSide(err) || isNetworkOrTimeout(err) || isSdkRetryable(err) || isClockSkewCorrected(err);
128
119
  }
120
+ function transientFaultMessage(err) {
121
+ return `transient S3 fault: ${errorName(err) ?? "unknown"}`;
122
+ }
129
123
  function spanFromContentRange(contentRange) {
130
124
  if (contentRange === void 0) return void 0;
131
125
  const match = /^\s*bytes\s+(\d+)-(\d+)\//.exec(contentRange);
@@ -313,6 +307,128 @@ function scrubCredentials(err) {
313
307
  return safe;
314
308
  }
315
309
 
310
+ // src/client-probe.ts
311
+ import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
312
+ var PROBE_KEY = "cloudbitmaps-probe";
313
+ var PLACEHOLDER_CREDENTIALS = { accessKeyId: "cloudbitmaps-probe", secretAccessKey: "unused" };
314
+ var NEVER_SENDS = {
315
+ handle: () => Promise.reject(new Error("the client probe was asked to send a request")),
316
+ updateHttpClientConfig: () => {
317
+ },
318
+ httpHandlerConfigs: () => ({}),
319
+ destroy: () => {
320
+ }
321
+ };
322
+ var SILENT_LOGGER = {
323
+ debug: () => {
324
+ },
325
+ info: () => {
326
+ },
327
+ warn: () => {
328
+ },
329
+ error: () => {
330
+ }
331
+ };
332
+ function cloneForProbe(client) {
333
+ const parts = client;
334
+ if (typeof parts.send !== "function" || typeof parts.config !== "object" || parts.config === null) {
335
+ return void 0;
336
+ }
337
+ if (typeof parts.constructor !== "function" || parts.constructor === Object) return void 0;
338
+ const Class = parts.constructor;
339
+ const clone = new Class({
340
+ ...parts.config,
341
+ credentials: PLACEHOLDER_CREDENTIALS,
342
+ requestHandler: NEVER_SENDS,
343
+ logger: SILENT_LOGGER
344
+ });
345
+ return typeof clone.send === "function" && clone.middlewareStack != null ? clone : void 0;
346
+ }
347
+ async function serialise(client, command) {
348
+ let seen;
349
+ command.middlewareStack.addRelativeTo(
350
+ () => (args) => {
351
+ seen = args.request;
352
+ return Promise.resolve({ response: {}, output: { $metadata: {} } });
353
+ },
354
+ { relation: "after", toMiddleware: "serializerMiddleware", name: "cloudbitmapsProbe" }
355
+ );
356
+ const handler = command.resolveMiddleware(client.middlewareStack, client.config, {});
357
+ await handler(command);
358
+ if (seen === void 0) throw new Error("the probe saw no request");
359
+ return seen;
360
+ }
361
+ var hasHeader = (request, name) => request.headers?.[name] !== void 0;
362
+ async function probeClient(client, bucket) {
363
+ let probe;
364
+ try {
365
+ probe = cloneForProbe(client);
366
+ if (probe === void 0) return void 0;
367
+ const del = await serialise(
368
+ probe,
369
+ new DeleteObjectCommand({ Bucket: bucket, Key: PROBE_KEY, IfMatch: '"probe"' })
370
+ );
371
+ const put = await serialise(
372
+ probe,
373
+ new PutObjectCommand({
374
+ Bucket: bucket,
375
+ Key: PROBE_KEY,
376
+ Body: new Uint8Array(0),
377
+ IfMatch: '"probe"',
378
+ IfNoneMatch: "*"
379
+ })
380
+ );
381
+ if (typeof del.hostname !== "string" || del.hostname === "") return void 0;
382
+ return {
383
+ host: del.hostname,
384
+ sendsDeleteIfMatch: hasHeader(del, "if-match"),
385
+ sendsPutIfMatch: hasHeader(put, "if-match"),
386
+ sendsPutIfNoneMatch: hasHeader(put, "if-none-match")
387
+ };
388
+ } catch {
389
+ return void 0;
390
+ } finally {
391
+ probe?.destroy();
392
+ }
393
+ }
394
+ function appliesDeleteIfMatch(explicit, facts) {
395
+ if (explicit === false) return false;
396
+ if (explicit === true) return facts?.sendsDeleteIfMatch !== false;
397
+ return facts !== void 0 && facts.sendsDeleteIfMatch && isAwsS3Host(facts.host);
398
+ }
399
+ var AWS_DOMAINS = [".amazonaws.com", ".amazonaws.com.cn"];
400
+ var S3_LABELS = /* @__PURE__ */ new Set([
401
+ "s3",
402
+ "s3-fips",
403
+ "s3-accesspoint",
404
+ "s3-accesspoint-fips",
405
+ "s3-global",
406
+ "s3-object-lambda",
407
+ "s3-outposts",
408
+ "s3-external-1"
409
+ ]);
410
+ var S3_DASH_REGION_LABEL = /^s3-(fips-)?[a-z]{2}(-gov)?-[a-z]+-\d$/;
411
+ var S3_EXPRESS_LABEL = /^s3express-[a-z0-9-]+$/;
412
+ var REGION_LABEL = /^[a-z]{2}(-[a-z]+)+-\d+$/;
413
+ var isS3Label = (label) => S3_LABELS.has(label) || S3_DASH_REGION_LABEL.test(label) || S3_EXPRESS_LABEL.test(label);
414
+ function isAwsS3Host(hostname) {
415
+ const host = hostname.toLowerCase().replace(/\.$/, "");
416
+ const domain = AWS_DOMAINS.find((d) => host.endsWith(d));
417
+ if (domain === void 0) return false;
418
+ const labels = host.slice(0, -domain.length).split(".");
419
+ let at = -1;
420
+ labels.forEach((label, i) => {
421
+ if (isS3Label(label)) at = i;
422
+ });
423
+ if (at === -1) return false;
424
+ const rest = labels.slice(at + 1);
425
+ let k = 0;
426
+ if (rest[k] === "dualstack") k++;
427
+ if (k < rest.length && REGION_LABEL.test(rest[k])) k++;
428
+ if (rest[k] === "vpce") k++;
429
+ return k === rest.length;
430
+ }
431
+
316
432
  // src/storage.ts
317
433
  var S3_PART_BYTES = 8 * 1024 * 1024;
318
434
  var S3_MAX_PARTS = 1e4;
@@ -324,6 +440,7 @@ var THROTTLE_BASE_DELAY_MS = 500;
324
440
  var REAL_TIME = {
325
441
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
326
442
  };
443
+ var REAL_RNG = { next: () => Math.random() };
327
444
  var S3StorageDriver = class {
328
445
  client;
329
446
  bucket;
@@ -332,21 +449,21 @@ var S3StorageDriver = class {
332
449
  partBytes;
333
450
  readTimeoutMs;
334
451
  clock;
452
+ rng;
335
453
  sockets;
454
+ explicitConditionalDelete;
455
+ probed;
456
+ facts;
336
457
  constructor(options) {
337
458
  this.client = options.client;
459
+ this.explicitConditionalDelete = requireBool("conditionalDelete", options.conditionalDelete);
338
460
  this.sockets = options.sockets;
339
461
  this.bucket = options.bucket;
340
462
  this.prefix = normalizeObjectPrefix(options.prefix);
341
463
  this.clock = options.clock ?? REAL_TIME;
342
- for (const [name, value] of [
343
- ["partBytes", options.partBytes],
344
- ["maxObjectBytes", options.maxObjectBytes]
345
- ]) {
346
- if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) {
347
- throw new ValidationError3(`${name} must be a positive safe integer; got ${value}`);
348
- }
349
- }
464
+ this.rng = options.rng ?? REAL_RNG;
465
+ requirePositiveInt("partBytes", options.partBytes);
466
+ requirePositiveInt("maxObjectBytes", options.maxObjectBytes);
350
467
  if (options.maxObjectBytes !== void 0 && options.maxObjectBytes > S3_MAX_OBJECT_BYTES) {
351
468
  throw new ValidationError3(
352
469
  `maxObjectBytes must be at most S3's 5 TiB object limit (${S3_MAX_OBJECT_BYTES}); got ${options.maxObjectBytes}`
@@ -363,7 +480,19 @@ var S3StorageDriver = class {
363
480
  this.readTimeoutMs = resolveReadTimeoutMs(options.readTimeoutMs);
364
481
  }
365
482
  capabilities() {
366
- return { rangeRead: true, maxObjectBytes: this.maxObjectBytes, conditionalPut: true };
483
+ return {
484
+ rangeRead: true,
485
+ maxObjectBytes: this.maxObjectBytes,
486
+ conditionalPut: true,
487
+ conditionalDelete: appliesDeleteIfMatch(this.explicitConditionalDelete, this.facts)
488
+ };
489
+ }
490
+ /** Ask the client once where its requests go and whether it sends `If-Match` on a delete; it sends nothing. */
491
+ settle() {
492
+ this.probed ??= probeClient(this.client, this.bucket).then((facts) => {
493
+ this.facts = facts;
494
+ });
495
+ return this.probed;
367
496
  }
368
497
  async putImmutable(key, write) {
369
498
  const objectKey = storageObjectKey(this.prefix, key);
@@ -374,7 +503,8 @@ var S3StorageDriver = class {
374
503
  this.partBytes,
375
504
  this.maxObjectBytes,
376
505
  this.readTimeoutMs,
377
- this.clock
506
+ this.clock,
507
+ this.rng
378
508
  );
379
509
  try {
380
510
  await write(sink);
@@ -432,8 +562,12 @@ var S3StorageDriver = class {
432
562
  }
433
563
  async getTail(key, maxBytes) {
434
564
  const objectKey = storageObjectKey(this.prefix, key);
565
+ if (!Number.isSafeInteger(maxBytes)) {
566
+ throw new ValidationError3(`invalid tail length ${String(maxBytes)}`);
567
+ }
435
568
  if (maxBytes <= 0) {
436
- return { bytes: new Uint8Array(0), size: await this.headSize(key, objectKey) ?? 0 };
569
+ const head = await this.head(key, objectKey);
570
+ return withVersion({ bytes: new Uint8Array(0), size: head.size ?? 0 }, head.version);
437
571
  }
438
572
  const read = await this.read("GetObject", key, async (options) => {
439
573
  try {
@@ -451,17 +585,18 @@ var S3StorageDriver = class {
451
585
  () => this.badRead(key, "tail", `the response is longer than the ${maxBytes}B requested`),
452
586
  res.ContentLength
453
587
  );
454
- return { bytes: bytes2, contentRange: res.ContentRange };
588
+ return { bytes: bytes2, contentRange: res.ContentRange, version: res.ETag };
455
589
  } catch (err) {
456
590
  if (isInvalidRange(err)) return { refused: err };
457
591
  throw err;
458
592
  }
459
593
  });
460
594
  if ("refused" in read) {
461
- if (await this.headSize(key, objectKey) !== 0) throw this.mapReadError(read.refused, key);
462
- return { bytes: new Uint8Array(0), size: 0 };
595
+ const head = await this.head(key, objectKey);
596
+ if (head.size !== 0) throw this.mapReadError(read.refused, key);
597
+ return withVersion({ bytes: new Uint8Array(0), size: 0 }, head.version);
463
598
  }
464
- const { bytes, contentRange } = read;
599
+ const { bytes, contentRange, version } = read;
465
600
  let size = totalFromContentRange(contentRange);
466
601
  if (contentRange !== void 0 && !(size === 0 && bytes.length === 0)) {
467
602
  const span = spanFromContentRange(contentRange);
@@ -475,23 +610,26 @@ var S3StorageDriver = class {
475
610
  }
476
611
  }
477
612
  if (size === void 0) {
478
- size = bytes.length === maxBytes ? await this.headSize(key, objectKey) ?? bytes.length : bytes.length;
613
+ size = bytes.length === maxBytes ? (await this.head(key, objectKey)).size ?? bytes.length : bytes.length;
479
614
  }
480
- return { bytes, size };
615
+ return withVersion({ bytes, size }, version);
481
616
  }
482
617
  badRead(key, what, why) {
483
618
  return new ValidationError3(
484
619
  `S3 ${what} read of ${key.segment}.${key.generation} refused: ${why}`
485
620
  );
486
621
  }
487
- /** The object's size from a `HeadObject`, or `undefined` when the response does not carry one. */
488
- headSize(key, objectKey) {
622
+ /**
623
+ * The object's size and ETag from a `HeadObject`, each `undefined` when the response does not carry it. A missing
624
+ * object throws {@link NotFoundError}.
625
+ */
626
+ head(key, objectKey) {
489
627
  return this.read("HeadObject", key, async (options) => {
490
628
  const head = await this.client.send(
491
629
  new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }),
492
630
  options
493
631
  );
494
- return head.ContentLength;
632
+ return { size: head.ContentLength, version: head.ETag };
495
633
  });
496
634
  }
497
635
  /**
@@ -509,15 +647,43 @@ var S3StorageDriver = class {
509
647
  }
510
648
  });
511
649
  }
512
- async delete(key) {
650
+ async delete(key, options) {
651
+ const objectKey = storageObjectKey(this.prefix, key);
652
+ const ifVersion = options?.ifVersion;
653
+ if (ifVersion !== void 0 && this.explicitConditionalDelete !== false) await this.settle();
654
+ const conditional = ifVersion !== void 0 && appliesDeleteIfMatch(this.explicitConditionalDelete, this.facts);
513
655
  try {
514
656
  await this.client.send(
515
- new DeleteObjectCommand({ Bucket: this.bucket, Key: storageObjectKey(this.prefix, key) })
657
+ new DeleteObjectCommand2({
658
+ Bucket: this.bucket,
659
+ Key: objectKey,
660
+ ...conditional ? { IfMatch: ifVersion } : {}
661
+ })
516
662
  );
517
- } catch (err) {
663
+ } catch (raw) {
664
+ const err = scrubCredentials(raw);
665
+ if (conditional) {
666
+ if (isNotFound(err)) return;
667
+ if (isConditionalConflict(err)) {
668
+ if (!await this.exists(key, objectKey)) return;
669
+ throw new WriteConflictError(
670
+ `generation ${key.segment}.${key.generation} is another object than the version given; not deleted`
671
+ );
672
+ }
673
+ }
518
674
  throw this.mapError(err);
519
675
  }
520
676
  }
677
+ /** Whether an object is under the key, from one `HeadObject`. A read that fails in any other way throws. */
678
+ async exists(key, objectKey) {
679
+ try {
680
+ await this.head(key, objectKey);
681
+ return true;
682
+ } catch (err) {
683
+ if (isNotFoundError(err)) return false;
684
+ throw err;
685
+ }
686
+ }
521
687
  async *list(ref) {
522
688
  const prefix = segmentObjectPrefix(this.prefix, ref);
523
689
  let token;
@@ -563,33 +729,21 @@ var S3StorageDriver = class {
563
729
  return this.mapError(err);
564
730
  }
565
731
  /**
566
- * Reclassify a transient S3 fault (throttle/5xx/dropped connection) as a retryable {@link TransientError},
567
- * so the store's read retry can ride it out and a write's caller can tell it from a deterministic failure;
568
- * everything else propagates unchanged. The final fallback at every `client.send` site, so callers and the
569
- * read retry only ever see typed errors.
732
+ * Reclassify a transient S3 fault (throttle/5xx/dropped connection) as a retryable {@link TransientError}, so the
733
+ * store's read retry can ride it out and a write's caller can tell it from a deterministic failure, and a missing
734
+ * bucket as a `ValidationError` that names it; everything else propagates unchanged. The final fallback at every
735
+ * `client.send` site, so callers and the read retry only ever see typed errors.
570
736
  */
571
737
  mapError(err) {
572
- err = scrubCredentials(err);
573
- if (isTransient(err)) {
574
- return new TransientError2(
575
- `transient S3 fault: ${err?.name ?? "unknown"}`,
576
- { cause: err }
577
- );
578
- }
579
- return err;
738
+ const scrubbed = asMissingBucket(scrubCredentials(err), this.bucket);
739
+ return toTransient(scrubbed, isTransient, transientFaultMessage);
580
740
  }
581
741
  };
582
- function concatBytes(parts, total) {
583
- const out = new Uint8Array(total);
584
- let offset = 0;
585
- for (const p of parts) {
586
- out.set(p, offset);
587
- offset += p.length;
588
- }
589
- return out;
742
+ function withVersion(tail, version) {
743
+ return version === void 0 ? tail : { ...tail, version };
590
744
  }
591
745
  var S3MultipartSink = class {
592
- constructor(client, bucket, objectKey, partBytes, maxObjectBytes, readTimeoutMs, clock) {
746
+ constructor(client, bucket, objectKey, partBytes, maxObjectBytes, readTimeoutMs, clock, rng) {
593
747
  this.client = client;
594
748
  this.bucket = bucket;
595
749
  this.objectKey = objectKey;
@@ -597,6 +751,7 @@ var S3MultipartSink = class {
597
751
  this.maxObjectBytes = maxObjectBytes;
598
752
  this.readTimeoutMs = readTimeoutMs;
599
753
  this.clock = clock;
754
+ this.rng = rng;
600
755
  }
601
756
  client;
602
757
  bucket;
@@ -605,6 +760,7 @@ var S3MultipartSink = class {
605
760
  maxObjectBytes;
606
761
  readTimeoutMs;
607
762
  clock;
763
+ rng;
608
764
  hash = createHash("sha256");
609
765
  pending = [];
610
766
  pendingLen = 0;
@@ -641,7 +797,7 @@ var S3MultipartSink = class {
641
797
  }
642
798
  this.uploadId = res2.UploadId;
643
799
  }
644
- const body = concatBytes(this.pending, this.pendingLen);
800
+ const body = concatBytes(this.pending);
645
801
  this.pending.length = 0;
646
802
  this.pendingLen = 0;
647
803
  this.partNumber += 1;
@@ -663,12 +819,12 @@ var S3MultipartSink = class {
663
819
  async finish() {
664
820
  const sha256 = this.hash.digest("hex");
665
821
  if (this.uploadId === void 0) {
666
- const body = concatBytes(this.pending, this.pendingLen);
822
+ const body = concatBytes(this.pending);
667
823
  await this.commit(
668
824
  "PutObject",
669
825
  () => sendOnce(
670
826
  this.client,
671
- new PutObjectCommand({
827
+ new PutObjectCommand2({
672
828
  Bucket: this.bucket,
673
829
  Key: this.objectKey,
674
830
  Body: body,
@@ -740,7 +896,7 @@ var S3MultipartSink = class {
740
896
  { cause: err }
741
897
  );
742
898
  }
743
- await this.clock.sleep(Math.floor(Math.random() * THROTTLE_BASE_DELAY_MS * 2 ** resent));
899
+ await this.clock.sleep(Math.floor(this.rng.next() * THROTTLE_BASE_DELAY_MS * 2 ** resent));
744
900
  }
745
901
  }
746
902
  }
@@ -784,10 +940,11 @@ import {
784
940
  isIntegrityError,
785
941
  MAX_ROW_BYTES,
786
942
  ObjectStoreRegistry,
787
- TransientError as TransientError3,
788
943
  ValidationError as ValidationError4,
789
944
  WriteConflictError as WriteConflictError2,
790
- normalizeObjectPrefix as normalizeObjectPrefix2
945
+ normalizeObjectPrefix as normalizeObjectPrefix2,
946
+ requireBool as requireBool2,
947
+ toTransient as toTransient2
791
948
  } from "@cloudbitmaps/core/driver-kit";
792
949
  import {
793
950
  DeleteObjectCommand as DeleteObjectCommand3,
@@ -795,125 +952,6 @@ import {
795
952
  ListObjectsV2Command as ListObjectsV2Command2,
796
953
  PutObjectCommand as PutObjectCommand3
797
954
  } from "@aws-sdk/client-s3";
798
-
799
- // src/client-probe.ts
800
- import { DeleteObjectCommand as DeleteObjectCommand2, PutObjectCommand as PutObjectCommand2 } from "@aws-sdk/client-s3";
801
- var PROBE_KEY = "cloudbitmaps-probe";
802
- var PLACEHOLDER_CREDENTIALS = { accessKeyId: "cloudbitmaps-probe", secretAccessKey: "unused" };
803
- var NEVER_SENDS = {
804
- handle: () => Promise.reject(new Error("the client probe was asked to send a request")),
805
- updateHttpClientConfig: () => {
806
- },
807
- httpHandlerConfigs: () => ({}),
808
- destroy: () => {
809
- }
810
- };
811
- var SILENT_LOGGER = {
812
- debug: () => {
813
- },
814
- info: () => {
815
- },
816
- warn: () => {
817
- },
818
- error: () => {
819
- }
820
- };
821
- function cloneForProbe(client) {
822
- const parts = client;
823
- if (typeof parts.send !== "function" || typeof parts.config !== "object" || parts.config === null) {
824
- return void 0;
825
- }
826
- if (typeof parts.constructor !== "function" || parts.constructor === Object) return void 0;
827
- const Class = parts.constructor;
828
- const clone = new Class({
829
- ...parts.config,
830
- credentials: PLACEHOLDER_CREDENTIALS,
831
- requestHandler: NEVER_SENDS,
832
- logger: SILENT_LOGGER
833
- });
834
- return typeof clone.send === "function" && clone.middlewareStack != null ? clone : void 0;
835
- }
836
- async function serialise(client, command) {
837
- let seen;
838
- command.middlewareStack.addRelativeTo(
839
- () => (args) => {
840
- seen = args.request;
841
- return Promise.resolve({ response: {}, output: { $metadata: {} } });
842
- },
843
- { relation: "after", toMiddleware: "serializerMiddleware", name: "cloudbitmapsProbe" }
844
- );
845
- const handler = command.resolveMiddleware(client.middlewareStack, client.config, {});
846
- await handler(command);
847
- if (seen === void 0) throw new Error("the probe saw no request");
848
- return seen;
849
- }
850
- var hasHeader = (request, name) => request.headers?.[name] !== void 0;
851
- async function probeClient(client, bucket) {
852
- let probe;
853
- try {
854
- probe = cloneForProbe(client);
855
- if (probe === void 0) return void 0;
856
- const del = await serialise(
857
- probe,
858
- new DeleteObjectCommand2({ Bucket: bucket, Key: PROBE_KEY, IfMatch: '"probe"' })
859
- );
860
- const put = await serialise(
861
- probe,
862
- new PutObjectCommand2({
863
- Bucket: bucket,
864
- Key: PROBE_KEY,
865
- Body: new Uint8Array(0),
866
- IfMatch: '"probe"',
867
- IfNoneMatch: "*"
868
- })
869
- );
870
- if (typeof del.hostname !== "string" || del.hostname === "") return void 0;
871
- return {
872
- host: del.hostname,
873
- sendsDeleteIfMatch: hasHeader(del, "if-match"),
874
- sendsPutIfMatch: hasHeader(put, "if-match"),
875
- sendsPutIfNoneMatch: hasHeader(put, "if-none-match")
876
- };
877
- } catch {
878
- return void 0;
879
- } finally {
880
- probe?.destroy();
881
- }
882
- }
883
- var AWS_DOMAINS = [".amazonaws.com", ".amazonaws.com.cn"];
884
- var S3_LABELS = /* @__PURE__ */ new Set([
885
- "s3",
886
- "s3-fips",
887
- "s3-accesspoint",
888
- "s3-accesspoint-fips",
889
- "s3-global",
890
- "s3-object-lambda",
891
- "s3-outposts",
892
- "s3-external-1"
893
- ]);
894
- var S3_DASH_REGION_LABEL = /^s3-(fips-)?[a-z]{2}(-gov)?-[a-z]+-\d$/;
895
- var S3_EXPRESS_LABEL = /^s3express-[a-z0-9-]+$/;
896
- var REGION_LABEL = /^[a-z]{2}(-[a-z]+)+-\d+$/;
897
- var isS3Label = (label) => S3_LABELS.has(label) || S3_DASH_REGION_LABEL.test(label) || S3_EXPRESS_LABEL.test(label);
898
- function isAwsS3Host(hostname) {
899
- const host = hostname.toLowerCase().replace(/\.$/, "");
900
- const domain = AWS_DOMAINS.find((d) => host.endsWith(d));
901
- if (domain === void 0) return false;
902
- const labels = host.slice(0, -domain.length).split(".");
903
- let at = -1;
904
- labels.forEach((label, i) => {
905
- if (isS3Label(label)) at = i;
906
- });
907
- if (at === -1) return false;
908
- const rest = labels.slice(at + 1);
909
- let k = 0;
910
- if (rest[k] === "dualstack") k++;
911
- if (k < rest.length && REGION_LABEL.test(rest[k])) k++;
912
- if (rest[k] === "vpce") k++;
913
- return k === rest.length;
914
- }
915
-
916
- // src/registry.ts
917
955
  var S3RegistryStore = class {
918
956
  constructor(client, bucket, readTimeoutMs, explicitConditionalDelete) {
919
957
  this.client = client;
@@ -934,10 +972,7 @@ var S3RegistryStore = class {
934
972
  * vouched for the store.
935
973
  */
936
974
  get conditionalDelete() {
937
- if (this.explicitConditionalDelete === false) return false;
938
- const facts = this.facts;
939
- if (this.explicitConditionalDelete === true) return facts?.sendsDeleteIfMatch !== false;
940
- return facts !== void 0 && facts.sendsDeleteIfMatch && isAwsS3Host(facts.host);
975
+ return appliesDeleteIfMatch(this.explicitConditionalDelete, this.facts);
941
976
  }
942
977
  /** Ask the client once what it does with the registry's requests; every caller waits for the same answer. */
943
978
  settle() {
@@ -967,7 +1002,7 @@ var S3RegistryStore = class {
967
1002
  );
968
1003
  } catch (err) {
969
1004
  if (isNotFound(err)) return null;
970
- throw mapError(err);
1005
+ throw mapError(err, this.bucket);
971
1006
  }
972
1007
  try {
973
1008
  if ((res.ContentLength ?? 0) > MAX_ROW_BYTES) {
@@ -986,7 +1021,7 @@ var S3RegistryStore = class {
986
1021
  return { bytes, version: res.ETag ?? "" };
987
1022
  } catch (err) {
988
1023
  destroyBody(res.Body);
989
- throw isIntegrityError(err) ? err : mapError(err);
1024
+ throw isIntegrityError(err) ? err : mapError(err, this.bucket);
990
1025
  }
991
1026
  });
992
1027
  }
@@ -1009,7 +1044,7 @@ var S3RegistryStore = class {
1009
1044
  if (isConditionalConflict(err)) {
1010
1045
  throw new WriteConflictError2(`registry OCC conflict for ${key}`);
1011
1046
  }
1012
- throw mapError(err);
1047
+ throw mapError(err, this.bucket);
1013
1048
  }
1014
1049
  }
1015
1050
  async delete(key, expect) {
@@ -1022,7 +1057,7 @@ var S3RegistryStore = class {
1022
1057
  if (isConditionalConflict(err) || isNotFound(err)) {
1023
1058
  throw new WriteConflictError2(`registry OCC conflict deleting ${key}`);
1024
1059
  }
1025
- throw mapError(err);
1060
+ throw mapError(err, this.bucket);
1026
1061
  }
1027
1062
  }
1028
1063
  async *listKeys(prefix) {
@@ -1039,7 +1074,7 @@ var S3RegistryStore = class {
1039
1074
  })
1040
1075
  );
1041
1076
  } catch (err) {
1042
- throw mapError(err);
1077
+ throw mapError(err, this.bucket);
1043
1078
  }
1044
1079
  for (const obj of res.Contents ?? []) {
1045
1080
  if (obj.Key !== void 0) yield obj.Key;
@@ -1054,22 +1089,12 @@ var S3RegistryStore = class {
1054
1089
  } while (token !== void 0);
1055
1090
  }
1056
1091
  };
1057
- function mapError(err) {
1058
- err = scrubCredentials(err);
1059
- if (isTransient(err)) {
1060
- return new TransientError3(
1061
- `transient S3 fault: ${err?.name ?? "unknown"}`,
1062
- { cause: err }
1063
- );
1064
- }
1065
- return err;
1066
- }
1067
- function checkConditionalDelete(value) {
1068
- if (value === void 0) return void 0;
1069
- if (typeof value !== "boolean") {
1070
- throw new ValidationError4(`conditionalDelete must be a boolean; got ${String(value)}`);
1071
- }
1072
- return value;
1092
+ function mapError(err, bucket) {
1093
+ return toTransient2(
1094
+ asMissingBucket(scrubCredentials(err), bucket),
1095
+ isTransient,
1096
+ transientFaultMessage
1097
+ );
1073
1098
  }
1074
1099
  var S3RegistryDriver = class extends ObjectStoreRegistry {
1075
1100
  constructor(options) {
@@ -1078,7 +1103,7 @@ var S3RegistryDriver = class extends ObjectStoreRegistry {
1078
1103
  options.client,
1079
1104
  options.bucket,
1080
1105
  resolveReadTimeoutMs(options.readTimeoutMs),
1081
- checkConditionalDelete(options.conditionalDelete)
1106
+ requireBool2("conditionalDelete", options.conditionalDelete)
1082
1107
  ),
1083
1108
  normalizeObjectPrefix2(options.prefix),
1084
1109
  options.now ?? (() => Date.now())
@@ -1216,20 +1241,6 @@ function limitSockets(client, maxSockets) {
1216
1241
  return handle(request, options);
1217
1242
  };
1218
1243
  }
1219
- function refuseUnknown(name, options, keys, hint) {
1220
- if (options === null || typeof options !== "object") {
1221
- throw new ValidationError5(
1222
- `${name} needs an options object \u2014 got ${options === null ? "null" : typeof options}`
1223
- );
1224
- }
1225
- const unknown = Object.keys(options).filter((k) => !keys.includes(k));
1226
- if (unknown.length > 0) {
1227
- const list = (ks) => ks.map((k) => `\`${k}\``).join(", ");
1228
- throw new ValidationError5(
1229
- `${name} does not take ${list(unknown)}. It takes ${list(keys)}; ${hint}.`
1230
- );
1231
- }
1232
- }
1233
1244
  var S3Storage = class {
1234
1245
  storage;
1235
1246
  registry;
@@ -1237,7 +1248,12 @@ var S3Storage = class {
1237
1248
  client;
1238
1249
  sockets;
1239
1250
  constructor(options) {
1240
- refuseUnknown("S3Storage", options, S3_STORAGE_OPTION_KEYS, "an S3 client goes in `client`");
1251
+ refuseUnknownOptions(
1252
+ "S3Storage",
1253
+ options,
1254
+ S3_STORAGE_OPTION_KEYS,
1255
+ "an S3 client goes in `client`"
1256
+ );
1241
1257
  if (typeof options.bucket !== "string" || options.bucket.length === 0) {
1242
1258
  throw new ValidationError5("S3Storage needs a `bucket`: the name of one, a non-empty string");
1243
1259
  }
@@ -1255,37 +1271,35 @@ var S3Storage = class {
1255
1271
  }
1256
1272
  this.client = options.client;
1257
1273
  } else {
1258
- const { maxSockets = DEFAULT_MAX_SOCKETS } = options;
1259
- if (!Number.isSafeInteger(maxSockets) || maxSockets < 1) {
1260
- throw new ValidationError5(
1261
- `maxSockets must be a positive safe integer; got ${describe(options.maxSockets)}`
1262
- );
1263
- }
1264
- this.client = new S3Client({
1265
- ...options.region === void 0 ? {} : { region: options.region },
1266
- ...options.endpoint === void 0 ? {} : { endpoint: options.endpoint },
1267
- ...options.pathStyle === void 0 ? {} : { forcePathStyle: options.pathStyle },
1268
- ...options.credentials === void 0 ? {} : { credentials: options.credentials }
1269
- });
1274
+ const maxSockets = requirePositiveInt2("maxSockets", options.maxSockets) ?? DEFAULT_MAX_SOCKETS;
1275
+ this.client = new S3Client(
1276
+ defined({
1277
+ credentials: options.credentials,
1278
+ region: options.region,
1279
+ endpoint: options.endpoint,
1280
+ forcePathStyle: options.pathStyle
1281
+ })
1282
+ );
1270
1283
  limitSockets(this.client, maxSockets);
1271
1284
  }
1272
1285
  this.sockets = new SocketAdvisory(this.client, options.bucket);
1273
1286
  const shared = {
1274
1287
  client: this.client,
1275
1288
  bucket: options.bucket,
1276
- ...options.prefix === void 0 ? {} : { prefix: options.prefix },
1277
- ...options.readTimeoutMs === void 0 ? {} : { readTimeoutMs: options.readTimeoutMs }
1289
+ ...defined({ prefix: options.prefix, readTimeoutMs: options.readTimeoutMs })
1278
1290
  };
1279
1291
  this.storage = new S3StorageDriver({
1280
1292
  ...shared,
1281
1293
  sockets: this.sockets,
1282
- ...options.maxObjectBytes === void 0 ? {} : { maxObjectBytes: options.maxObjectBytes },
1283
- ...options.partBytes === void 0 ? {} : { partBytes: options.partBytes }
1294
+ ...defined({
1295
+ maxObjectBytes: options.maxObjectBytes,
1296
+ partBytes: options.partBytes,
1297
+ conditionalDelete: options.conditionalDelete
1298
+ })
1284
1299
  });
1285
1300
  this.registry = new S3RegistryDriver({
1286
1301
  ...shared,
1287
- ...options.now === void 0 ? {} : { now: options.now },
1288
- ...options.conditionalDelete === void 0 ? {} : { conditionalDelete: options.conditionalDelete }
1302
+ ...defined({ now: options.now, conditionalDelete: options.conditionalDelete })
1289
1303
  });
1290
1304
  brandAsBackend(this);
1291
1305
  }