@opengeni/storage 0.2.75 → 0.2.93

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,5 +1,7 @@
1
1
  // src/index.ts
2
2
  import { RETAINED_OUTPUT_MAX_PAGE_BYTES } from "@opengeni/contracts";
3
+ import { Readable } from "stream";
4
+ import { pipeline } from "stream/promises";
3
5
  import {
4
6
  BlobSASPermissions,
5
7
  BlobServiceClient,
@@ -17,9 +19,576 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
17
19
  import {
18
20
  Storage as GcsClient
19
21
  } from "@google-cloud/storage";
22
+
23
+ // src/bounded-object-read.ts
24
+ var DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES = 1024 * 1024;
25
+ var MAX_BOUNDED_OBJECT_CHUNK_BYTES = 8 * 1024 * 1024;
26
+ var BoundedObjectReadError = class extends Error {
27
+ code;
28
+ constructor(code) {
29
+ super(messageForCode(code));
30
+ this.name = "BoundedObjectReadError";
31
+ this.code = code;
32
+ }
33
+ };
34
+ function createBoundedObjectReadPort(backend) {
35
+ return Object.freeze({
36
+ async open(input) {
37
+ validateReference(input.opaqueReference);
38
+ assertPositiveSafeInteger(input.maxBytes);
39
+ if (input.expectedByteSize !== void 0) {
40
+ assertNonnegativeSafeInteger(input.expectedByteSize);
41
+ }
42
+ throwIfAborted(input.signal);
43
+ const description = await scrubBackendFailure(
44
+ async () => backend.describe({
45
+ opaqueReference: input.opaqueReference,
46
+ ...input.signal ? { signal: input.signal } : {}
47
+ })
48
+ );
49
+ throwIfAborted(input.signal);
50
+ if (!description) throw new BoundedObjectReadError("object_missing");
51
+ validateDescription(description);
52
+ if (description.byteSize > input.maxBytes) {
53
+ throw new BoundedObjectReadError("size_limit");
54
+ }
55
+ if (input.expectedByteSize !== void 0 && description.byteSize !== input.expectedByteSize) {
56
+ throw new BoundedObjectReadError("truncated");
57
+ }
58
+ return new VersionPinnedBoundedObjectRead(backend, input.opaqueReference, description);
59
+ }
60
+ });
61
+ }
62
+ var VersionPinnedBoundedObjectRead = class {
63
+ byteSize;
64
+ contentType;
65
+ #backend;
66
+ #opaqueReference;
67
+ #versionToken;
68
+ #claimed = false;
69
+ #closed = false;
70
+ constructor(backend, opaqueReference, description) {
71
+ this.#backend = backend;
72
+ this.#opaqueReference = opaqueReference;
73
+ this.#versionToken = description.versionToken;
74
+ this.byteSize = description.byteSize;
75
+ this.contentType = description.contentType;
76
+ }
77
+ chunks(input = {}) {
78
+ if (this.#closed || this.#claimed) {
79
+ throw new BoundedObjectReadError("invalid_request");
80
+ }
81
+ const chunkBytes = input.chunkBytes ?? DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES;
82
+ assertPositiveSafeInteger(chunkBytes);
83
+ if (chunkBytes > MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
84
+ throw new BoundedObjectReadError("size_limit");
85
+ }
86
+ this.#claimed = true;
87
+ return this.#streamChunks(chunkBytes, input.signal);
88
+ }
89
+ async *#streamChunks(chunkBytes, signal) {
90
+ let offset = 0;
91
+ let completed = false;
92
+ try {
93
+ while (offset < this.byteSize) {
94
+ throwIfAborted(signal);
95
+ if (this.#closed) {
96
+ throw new BoundedObjectReadError("invalid_request");
97
+ }
98
+ const endInclusive = Math.min(this.byteSize - 1, offset + chunkBytes - 1);
99
+ const range = await scrubBackendFailure(
100
+ async () => this.#backend.readRange({
101
+ opaqueReference: this.#opaqueReference,
102
+ start: offset,
103
+ endInclusive,
104
+ expectedVersionToken: this.#versionToken,
105
+ ...signal ? { signal } : {}
106
+ })
107
+ );
108
+ throwIfAborted(signal);
109
+ if (!range) {
110
+ throw new BoundedObjectReadError("object_changed");
111
+ }
112
+ if (range.versionToken !== this.#versionToken) {
113
+ throw new BoundedObjectReadError("object_changed");
114
+ }
115
+ const expectedLength = endInclusive - offset + 1;
116
+ if (range.bytes.byteLength !== expectedLength) {
117
+ throw new BoundedObjectReadError("truncated");
118
+ }
119
+ offset += range.bytes.byteLength;
120
+ yield range.bytes.slice();
121
+ }
122
+ throwIfAborted(signal);
123
+ completed = true;
124
+ } finally {
125
+ if (!completed) await this.close();
126
+ }
127
+ }
128
+ async assertUnchanged(signal) {
129
+ if (this.#closed) {
130
+ throw new BoundedObjectReadError("invalid_request");
131
+ }
132
+ throwIfAborted(signal);
133
+ const current = await scrubBackendFailure(
134
+ async () => this.#backend.describe({
135
+ opaqueReference: this.#opaqueReference,
136
+ ...signal ? { signal } : {}
137
+ })
138
+ );
139
+ throwIfAborted(signal);
140
+ if (!current || current.versionToken !== this.#versionToken || current.byteSize !== this.byteSize) {
141
+ throw new BoundedObjectReadError("object_changed");
142
+ }
143
+ }
144
+ async close() {
145
+ if (this.#closed) return;
146
+ this.#closed = true;
147
+ if (!this.#backend.close) return;
148
+ try {
149
+ await this.#backend.close({ opaqueReference: this.#opaqueReference });
150
+ } catch {
151
+ }
152
+ }
153
+ };
154
+ async function scrubBackendFailure(operation) {
155
+ try {
156
+ return await operation();
157
+ } catch (error) {
158
+ if (error instanceof BoundedObjectReadError) throw error;
159
+ throw new BoundedObjectReadError("backend_failure");
160
+ }
161
+ }
162
+ function validateReference(value) {
163
+ if (typeof value !== "string" || value.length < 1 || value.length > 2048 || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
164
+ throw new BoundedObjectReadError("invalid_request");
165
+ }
166
+ }
167
+ function validateDescription(value) {
168
+ assertNonnegativeSafeInteger(value.byteSize);
169
+ if (value.immutableReference !== true) {
170
+ throw new BoundedObjectReadError("backend_failure");
171
+ }
172
+ if (typeof value.versionToken !== "string" || value.versionToken.length < 1 || value.versionToken.length > 2048) {
173
+ throw new BoundedObjectReadError("backend_failure");
174
+ }
175
+ if (value.contentType !== void 0 && (value.contentType.length < 1 || value.contentType.length > 256)) {
176
+ throw new BoundedObjectReadError("backend_failure");
177
+ }
178
+ }
179
+ function assertPositiveSafeInteger(value) {
180
+ if (!Number.isSafeInteger(value) || value <= 0) {
181
+ throw new BoundedObjectReadError("invalid_request");
182
+ }
183
+ }
184
+ function assertNonnegativeSafeInteger(value) {
185
+ if (!Number.isSafeInteger(value) || value < 0) {
186
+ throw new BoundedObjectReadError("invalid_request");
187
+ }
188
+ }
189
+ function throwIfAborted(signal) {
190
+ if (signal?.aborted) throw new BoundedObjectReadError("aborted");
191
+ }
192
+ function messageForCode(code) {
193
+ switch (code) {
194
+ case "aborted":
195
+ return "Bounded object read was cancelled";
196
+ case "backend_failure":
197
+ return "Bounded object read failed";
198
+ case "invalid_request":
199
+ return "Bounded object read request is invalid";
200
+ case "object_changed":
201
+ return "Bounded object changed during verification";
202
+ case "object_missing":
203
+ return "Bounded object is unavailable";
204
+ case "size_limit":
205
+ return "Bounded object exceeds the configured limit";
206
+ case "truncated":
207
+ return "Bounded object length does not match its immutable metadata";
208
+ }
209
+ }
210
+
211
+ // src/bounded-object-write.ts
212
+ import { createHash } from "crypto";
213
+ var BoundedObjectWriteError = class extends Error {
214
+ code;
215
+ constructor(code) {
216
+ super(writeErrorMessage(code));
217
+ this.name = "BoundedObjectWriteError";
218
+ this.code = code;
219
+ }
220
+ };
221
+ function createBoundedImmutableObjectWritePort(input) {
222
+ return Object.freeze({
223
+ async write(request) {
224
+ validateWriteRequest(request);
225
+ throwIfAborted2(request.signal);
226
+ let session;
227
+ let committed = false;
228
+ try {
229
+ session = await scrubWriteBackend(
230
+ async () => input.backend.begin({
231
+ contentType: request.contentType,
232
+ ...request.signal ? { signal: request.signal } : {}
233
+ })
234
+ );
235
+ const digest = createHash("sha256");
236
+ let byteSize = 0;
237
+ for await (const sourceChunk of request.chunks) {
238
+ throwIfAborted2(request.signal);
239
+ if (!(sourceChunk instanceof Uint8Array) || sourceChunk.byteLength === 0) {
240
+ throw new BoundedObjectWriteError("truncated");
241
+ }
242
+ if (sourceChunk.byteLength > MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
243
+ throw new BoundedObjectWriteError("size_limit");
244
+ }
245
+ byteSize += sourceChunk.byteLength;
246
+ if (byteSize > request.maxBytes) {
247
+ throw new BoundedObjectWriteError("size_limit");
248
+ }
249
+ const chunk = sourceChunk.slice();
250
+ digest.update(chunk);
251
+ await scrubWriteBackend(async () => session.write(chunk, request.signal));
252
+ }
253
+ throwIfAborted2(request.signal);
254
+ if (request.expectedByteSize !== void 0 && byteSize !== request.expectedByteSize) {
255
+ throw new BoundedObjectWriteError("truncated");
256
+ }
257
+ const contentHash = `sha256:${digest.digest("hex")}`;
258
+ if (request.expectedContentHash !== void 0 && contentHash !== request.expectedContentHash) {
259
+ throw new BoundedObjectWriteError("content_hash_mismatch");
260
+ }
261
+ const promoted = await scrubWriteBackend(
262
+ async () => session.commit({
263
+ byteSize,
264
+ contentHash,
265
+ contentType: request.contentType,
266
+ ...request.signal ? { signal: request.signal } : {}
267
+ })
268
+ );
269
+ validateOpaqueReference(promoted.opaqueReference);
270
+ committed = true;
271
+ throwIfAborted2(request.signal);
272
+ const reader = await input.readback.open({
273
+ opaqueReference: promoted.opaqueReference,
274
+ maxBytes: request.maxBytes,
275
+ expectedByteSize: byteSize,
276
+ ...request.signal ? { signal: request.signal } : {}
277
+ });
278
+ try {
279
+ if (reader.contentType !== void 0 && reader.contentType !== request.contentType) {
280
+ throw new BoundedObjectWriteError("readback_mismatch");
281
+ }
282
+ const readbackHash = createHash("sha256");
283
+ let readbackBytes = 0;
284
+ for await (const chunk of reader.chunks({
285
+ ...request.signal ? { signal: request.signal } : {}
286
+ })) {
287
+ readbackBytes += chunk.byteLength;
288
+ if (readbackBytes > byteSize) {
289
+ throw new BoundedObjectWriteError("readback_mismatch");
290
+ }
291
+ readbackHash.update(chunk);
292
+ }
293
+ const readbackContentHash = `sha256:${readbackHash.digest("hex")}`;
294
+ if (readbackBytes !== byteSize || readbackContentHash !== contentHash) {
295
+ throw new BoundedObjectWriteError("readback_mismatch");
296
+ }
297
+ await reader.assertUnchanged(request.signal);
298
+ throwIfAborted2(request.signal);
299
+ } finally {
300
+ await reader.close();
301
+ }
302
+ return Object.freeze({
303
+ opaqueReference: promoted.opaqueReference,
304
+ byteSize,
305
+ contentHash,
306
+ contentType: request.contentType
307
+ });
308
+ } catch (error) {
309
+ throw mapWriteFailure(error, request.signal);
310
+ } finally {
311
+ if (session && !committed) {
312
+ try {
313
+ await session.abort();
314
+ } catch {
315
+ }
316
+ }
317
+ }
318
+ }
319
+ });
320
+ }
321
+ function validateWriteRequest(input) {
322
+ if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes <= 0) {
323
+ throw new BoundedObjectWriteError("invalid_request");
324
+ }
325
+ if (input.expectedByteSize !== void 0 && (!Number.isSafeInteger(input.expectedByteSize) || input.expectedByteSize < 0)) {
326
+ throw new BoundedObjectWriteError("invalid_request");
327
+ }
328
+ if (typeof input.contentType !== "string" || input.contentType.length < 1 || input.contentType.length > 256 || /[\u0000-\u001f\u007f]/.test(input.contentType)) {
329
+ throw new BoundedObjectWriteError("invalid_request");
330
+ }
331
+ if (input.expectedContentHash !== void 0 && !/^sha256:[0-9a-f]{64}$/.test(input.expectedContentHash)) {
332
+ throw new BoundedObjectWriteError("invalid_request");
333
+ }
334
+ }
335
+ function validateOpaqueReference(value) {
336
+ if (typeof value !== "string" || value.length < 1 || value.length > 2048 || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
337
+ throw new BoundedObjectWriteError("backend_failure");
338
+ }
339
+ }
340
+ async function scrubWriteBackend(operation) {
341
+ try {
342
+ return await operation();
343
+ } catch (error) {
344
+ if (error instanceof BoundedObjectWriteError) throw error;
345
+ throw new BoundedObjectWriteError("backend_failure");
346
+ }
347
+ }
348
+ function mapWriteFailure(error, signal) {
349
+ if (error instanceof BoundedObjectWriteError) return error;
350
+ if (signal?.aborted) return new BoundedObjectWriteError("aborted");
351
+ return new BoundedObjectWriteError("backend_failure");
352
+ }
353
+ function throwIfAborted2(signal) {
354
+ if (signal?.aborted) throw new BoundedObjectWriteError("aborted");
355
+ }
356
+ function writeErrorMessage(code) {
357
+ switch (code) {
358
+ case "aborted":
359
+ return "Bounded object write was cancelled";
360
+ case "backend_failure":
361
+ return "Bounded object write failed";
362
+ case "content_hash_mismatch":
363
+ return "Bounded object digest does not match expected canonical bytes";
364
+ case "invalid_request":
365
+ return "Bounded object write request is invalid";
366
+ case "readback_mismatch":
367
+ return "Immutable object read-back verification failed";
368
+ case "size_limit":
369
+ return "Bounded object write exceeds the configured limit";
370
+ case "truncated":
371
+ return "Bounded object write length does not match expected canonical bytes";
372
+ }
373
+ }
374
+
375
+ // src/object-storage-bounded.ts
376
+ import { createHash as createHash2 } from "crypto";
377
+ import { chmod, mkdtemp, open, rm } from "fs/promises";
378
+ import { tmpdir } from "os";
379
+ import { join } from "path";
380
+ var SNAPSHOT_KEY_PREFIX = "editable-artifacts/snapshots/sha256/";
381
+ function createObjectStorageBoundedPorts(storage, options = {}) {
382
+ if (!storage.headObject || !storage.getObjectRange || !storage.putObjectStreamIfAbsent) {
383
+ throw new Error("Object storage lacks streaming immutable create/versioned range primitives");
384
+ }
385
+ const keyPrefix = validateKeyPrefix(options.keyPrefix ?? SNAPSHOT_KEY_PREFIX);
386
+ const referenceHash = (reference) => hashForReference(reference, keyPrefix);
387
+ const read = createBoundedObjectReadPort(versionedBackend(storage, referenceHash));
388
+ const write = createBoundedImmutableObjectWritePort({
389
+ backend: immutableWriteBackend(storage, keyPrefix),
390
+ readback: read
391
+ });
392
+ return Object.freeze({ read, write });
393
+ }
394
+ function versionedBackend(storage, hashForReferenceValue) {
395
+ const headObject = storage.headObject;
396
+ const getObjectRange = storage.getObjectRange;
397
+ return Object.freeze({
398
+ async describe(input) {
399
+ const expectedHash = hashForReferenceValue(input.opaqueReference);
400
+ throwIfAborted3(input.signal);
401
+ const head = await headObject(input.opaqueReference);
402
+ throwIfAborted3(input.signal);
403
+ if (!head) return null;
404
+ if (!Number.isSafeInteger(head.ContentLength) || head.ContentLength < 0 || typeof head.VersionToken !== "string" || head.VersionToken.length < 1 || head.VersionToken.length > 2048 || head.Metadata?.sha256 !== expectedHash) {
405
+ throw new Error("Immutable object metadata is invalid");
406
+ }
407
+ return Object.freeze({
408
+ byteSize: head.ContentLength,
409
+ versionToken: head.VersionToken,
410
+ immutableReference: true,
411
+ ...head.ContentType ? { contentType: head.ContentType } : {}
412
+ });
413
+ },
414
+ async readRange(input) {
415
+ hashForReferenceValue(input.opaqueReference);
416
+ throwIfAborted3(input.signal);
417
+ const result = await getObjectRange({
418
+ key: input.opaqueReference,
419
+ start: input.start,
420
+ endInclusive: input.endInclusive,
421
+ expectedVersionToken: input.expectedVersionToken
422
+ });
423
+ throwIfAborted3(input.signal);
424
+ if (!result) return null;
425
+ return Object.freeze({
426
+ bytes: result.bytes.slice(),
427
+ versionToken: result.versionToken
428
+ });
429
+ }
430
+ });
431
+ }
432
+ function immutableWriteBackend(storage, keyPrefix) {
433
+ return Object.freeze({
434
+ async begin(input) {
435
+ validateContentType(input.contentType);
436
+ throwIfAborted3(input.signal);
437
+ const stagingDirectory = await mkdtemp(join(tmpdir(), "opengeni-artifact-write-"));
438
+ const stagingPath = join(stagingDirectory, "payload");
439
+ let handle = null;
440
+ try {
441
+ await chmod(stagingDirectory, 448);
442
+ throwIfAborted3(input.signal);
443
+ handle = await open(stagingPath, "wx", 384);
444
+ } catch (error) {
445
+ await rm(stagingDirectory, { recursive: true, force: true });
446
+ throw error;
447
+ }
448
+ let byteSize = 0;
449
+ let closed = false;
450
+ let cleaned = false;
451
+ const digest = createHash2("sha256");
452
+ const cleanup = async () => {
453
+ if (cleaned) return;
454
+ cleaned = true;
455
+ if (handle) {
456
+ const closing = handle;
457
+ handle = null;
458
+ await closing.close().catch(() => void 0);
459
+ }
460
+ await rm(stagingDirectory, { recursive: true, force: true });
461
+ };
462
+ return {
463
+ async write(chunk, signal) {
464
+ if (closed) throw new Error("Immutable write session is closed");
465
+ throwIfAborted3(signal);
466
+ if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) {
467
+ throw new Error("Immutable write chunk is invalid");
468
+ }
469
+ await writeAll(handle, chunk, byteSize);
470
+ digest.update(chunk);
471
+ byteSize += chunk.byteLength;
472
+ },
473
+ async commit(commit) {
474
+ if (closed) throw new Error("Immutable write session is closed");
475
+ closed = true;
476
+ try {
477
+ throwIfAborted3(commit.signal);
478
+ if (commit.contentType !== input.contentType || commit.byteSize !== byteSize) {
479
+ throw new Error("Immutable write commit metadata changed");
480
+ }
481
+ const contentHash = `sha256:${digest.digest("hex")}`;
482
+ if (contentHash !== commit.contentHash) {
483
+ throw new Error("Immutable write digest changed");
484
+ }
485
+ const opaqueReference = `${keyPrefix}${contentHash.slice("sha256:".length)}`;
486
+ const closing = handle;
487
+ handle = null;
488
+ await closing.close();
489
+ const existing = await storage.headObject(opaqueReference);
490
+ if (existing) {
491
+ assertStoredObject(existing, byteSize, contentHash, input.contentType);
492
+ } else {
493
+ await storage.putObjectStreamIfAbsent({
494
+ key: opaqueReference,
495
+ contentType: input.contentType,
496
+ chunks: fileChunks(stagingPath, byteSize, commit.signal),
497
+ byteSize,
498
+ sha256: contentHash,
499
+ ...commit.signal ? { signal: commit.signal } : {}
500
+ });
501
+ const stored = await storage.headObject(opaqueReference);
502
+ if (!stored) throw new Error("Immutable object was not visible after write");
503
+ assertStoredObject(stored, byteSize, contentHash, input.contentType);
504
+ }
505
+ throwIfAborted3(commit.signal);
506
+ return Object.freeze({ opaqueReference });
507
+ } finally {
508
+ await cleanup();
509
+ }
510
+ },
511
+ async abort() {
512
+ closed = true;
513
+ byteSize = 0;
514
+ await cleanup();
515
+ }
516
+ };
517
+ }
518
+ });
519
+ }
520
+ function assertStoredObject(head, byteSize, contentHash, contentType) {
521
+ if (!head || head.ContentLength !== byteSize || head.Metadata?.sha256 !== contentHash || head.ContentType !== contentType || typeof head.VersionToken !== "string" || head.VersionToken.length < 1) {
522
+ throw new Error("Immutable content-addressed object conflicts with stored metadata");
523
+ }
524
+ }
525
+ function hashForReference(reference, keyPrefix) {
526
+ const match = new RegExp(`^${escapeRegExp(keyPrefix)}([0-9a-f]{64})$`, "u").exec(reference);
527
+ if (!match) throw new Error("Immutable snapshot reference is malformed");
528
+ return `sha256:${match[1]}`;
529
+ }
530
+ function validateKeyPrefix(value) {
531
+ if (typeof value !== "string" || !/^editable-artifacts\/[a-z0-9/-]+\/sha256\/$/u.test(value) || value.includes("//") || value.includes("..")) {
532
+ throw new Error("Immutable object key prefix is invalid");
533
+ }
534
+ return value;
535
+ }
536
+ function escapeRegExp(value) {
537
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
538
+ }
539
+ function validateContentType(value) {
540
+ if (typeof value !== "string" || value.length < 1 || value.length > 256 || /[\u0000-\u001f\u007f]/u.test(value)) {
541
+ throw new Error("Immutable object content type is invalid");
542
+ }
543
+ }
544
+ async function* fileChunks(path, expectedByteSize, signal) {
545
+ const handle = await open(path, "r");
546
+ try {
547
+ const buffer = new Uint8Array(1024 * 1024);
548
+ let offset = 0;
549
+ while (offset < expectedByteSize) {
550
+ throwIfAborted3(signal);
551
+ const length = Math.min(buffer.byteLength, expectedByteSize - offset);
552
+ let filled = 0;
553
+ while (filled < length) {
554
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, offset + filled);
555
+ if (bytesRead <= 0) throw new Error("Immutable staging file was truncated");
556
+ filled += bytesRead;
557
+ }
558
+ offset += filled;
559
+ yield buffer.slice(0, filled);
560
+ }
561
+ const extra = new Uint8Array(1);
562
+ if ((await handle.read(extra, 0, 1, expectedByteSize)).bytesRead !== 0) {
563
+ throw new Error("Immutable staging file exceeded its committed size");
564
+ }
565
+ } finally {
566
+ await handle.close();
567
+ }
568
+ }
569
+ async function writeAll(handle, bytes, position) {
570
+ let offset = 0;
571
+ while (offset < bytes.byteLength) {
572
+ const { bytesWritten } = await handle.write(
573
+ bytes,
574
+ offset,
575
+ bytes.byteLength - offset,
576
+ position + offset
577
+ );
578
+ if (bytesWritten <= 0) throw new Error("Immutable staging write was truncated");
579
+ offset += bytesWritten;
580
+ }
581
+ }
582
+ function throwIfAborted3(signal) {
583
+ if (signal?.aborted) throw new Error("Object storage operation was cancelled");
584
+ }
585
+
586
+ // src/index.ts
20
587
  var MAX_SINGLE_PUT_SIZE_BYTES = 5e9;
21
588
  var UPLOAD_URL_TTL_SECONDS = 15 * 60;
22
589
  var DOWNLOAD_URL_TTL_SECONDS = 5 * 60;
590
+ var INTERNAL_STREAM_BUFFER_BYTES = 1024 * 1024;
591
+ var INTERNAL_STREAM_CONCURRENCY = 2;
23
592
  function createObjectStorage(settings) {
24
593
  if (settings.objectStorageBackend === "azure-blob") {
25
594
  return createAzureBlobObjectStorage(settings);
@@ -48,6 +617,10 @@ function createS3CompatibleObjectStorage(settings) {
48
617
  ...sharedClientConfig,
49
618
  ...settings.objectStorageEndpoint ? { endpoint: settings.objectStorageEndpoint } : {}
50
619
  });
620
+ const sandboxPresignClient = settings.objectStorageSandboxEndpoint ? new S3Client({
621
+ ...sharedClientConfig,
622
+ endpoint: settings.objectStorageSandboxEndpoint
623
+ }) : presignClient;
51
624
  const requestClient = settings.objectStorageInternalEndpoint ? new S3Client({
52
625
  ...sharedClientConfig,
53
626
  endpoint: settings.objectStorageInternalEndpoint
@@ -68,7 +641,13 @@ function createS3CompatibleObjectStorage(settings) {
68
641
  Metadata: args.sha256 ? { sha256: args.sha256 } : void 0
69
642
  });
70
643
  return {
71
- url: await getSignedUrl(presignClient, command, { expiresIn }),
644
+ url: await getSignedUrl(
645
+ args.audience === "sandbox" ? sandboxPresignClient : presignClient,
646
+ command,
647
+ {
648
+ expiresIn
649
+ }
650
+ ),
72
651
  requiredHeaders,
73
652
  expiresAt: new Date(Date.now() + expiresIn * 1e3)
74
653
  };
@@ -77,7 +656,7 @@ function createS3CompatibleObjectStorage(settings) {
77
656
  const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
78
657
  return {
79
658
  url: await getSignedUrl(
80
- presignClient,
659
+ args.audience === "sandbox" ? sandboxPresignClient : presignClient,
81
660
  new GetObjectCommand({
82
661
  Bucket: settings.objectStorageBucket,
83
662
  Key: args.key
@@ -98,6 +677,44 @@ function createS3CompatibleObjectStorage(settings) {
98
677
  })
99
678
  );
100
679
  },
680
+ async putObjectIfAbsent(args) {
681
+ try {
682
+ await requestClient.send(
683
+ new PutObjectCommand({
684
+ Bucket: settings.objectStorageBucket,
685
+ Key: args.key,
686
+ ContentType: args.contentType,
687
+ Body: args.body,
688
+ Metadata: { sha256: args.sha256 },
689
+ IfNoneMatch: "*"
690
+ })
691
+ );
692
+ return true;
693
+ } catch (error) {
694
+ if (isS3VersionMismatch(error)) return false;
695
+ throw error;
696
+ }
697
+ },
698
+ async putObjectStreamIfAbsent(args) {
699
+ try {
700
+ await requestClient.send(
701
+ new PutObjectCommand({
702
+ Bucket: settings.objectStorageBucket,
703
+ Key: args.key,
704
+ ContentType: args.contentType,
705
+ ContentLength: args.byteSize,
706
+ Body: Readable.from(args.chunks),
707
+ Metadata: { sha256: args.sha256 },
708
+ IfNoneMatch: "*"
709
+ }),
710
+ args.signal ? { abortSignal: args.signal } : void 0
711
+ );
712
+ return true;
713
+ } catch (error) {
714
+ if (isS3VersionMismatch(error)) return false;
715
+ throw error;
716
+ }
717
+ },
101
718
  async headFile(file) {
102
719
  const head = await requestClient.send(
103
720
  new HeadObjectCommand({
@@ -150,6 +767,43 @@ function createS3CompatibleObjectStorage(settings) {
150
767
  throw error;
151
768
  }
152
769
  },
770
+ async headObject(key) {
771
+ try {
772
+ const head = await requestClient.send(
773
+ new HeadObjectCommand({ Bucket: settings.objectStorageBucket, Key: key })
774
+ );
775
+ return objectHead({
776
+ contentLength: head.ContentLength,
777
+ contentType: head.ContentType,
778
+ metadata: head.Metadata,
779
+ versionToken: head.ETag
780
+ });
781
+ } catch (error) {
782
+ if (isS3NotFound(error)) return null;
783
+ throw error;
784
+ }
785
+ },
786
+ async getObjectRange(args) {
787
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
788
+ try {
789
+ const result = await requestClient.send(
790
+ new GetObjectCommand({
791
+ Bucket: settings.objectStorageBucket,
792
+ Key: args.key,
793
+ Range: `bytes=${args.start}-${args.endInclusive}`,
794
+ IfMatch: args.expectedVersionToken
795
+ })
796
+ );
797
+ if (!result.ETag || result.ETag !== args.expectedVersionToken) return null;
798
+ return {
799
+ bytes: await s3BodyToBoundedBytes(result.Body, args.key, length),
800
+ versionToken: result.ETag
801
+ };
802
+ } catch (error) {
803
+ if (isS3NotFound(error) || isS3VersionMismatch(error)) return null;
804
+ throw error;
805
+ }
806
+ },
153
807
  async getObjectBytes(key) {
154
808
  try {
155
809
  const result = await requestClient.send(
@@ -223,6 +877,11 @@ function isS3NotFound(error) {
223
877
  const metadata = "$metadata" in error ? error.$metadata : void 0;
224
878
  return metadata?.httpStatusCode === 404;
225
879
  }
880
+ function isS3VersionMismatch(error) {
881
+ if (!error || typeof error !== "object") return false;
882
+ const metadata = "$metadata" in error ? error.$metadata : void 0;
883
+ return metadata?.httpStatusCode === 412;
884
+ }
226
885
  function createGcsObjectStorage(settings) {
227
886
  const client = new GcsClient(gcsClientOptions(settings));
228
887
  const bucket = client.bucket(settings.objectStorageBucket);
@@ -268,6 +927,39 @@ function createGcsObjectStorage(settings) {
268
927
  ...args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}
269
928
  });
270
929
  },
930
+ async putObjectIfAbsent(args) {
931
+ try {
932
+ await bucket.file(args.key).save(Buffer.from(args.body), {
933
+ contentType: args.contentType,
934
+ metadata: { metadata: { sha256: args.sha256 } },
935
+ preconditionOpts: { ifGenerationMatch: 0 }
936
+ });
937
+ return true;
938
+ } catch (error) {
939
+ if (isGcsVersionMismatch(error)) return false;
940
+ throw error;
941
+ }
942
+ },
943
+ async putObjectStreamIfAbsent(args) {
944
+ const destination = bucket.file(args.key).createWriteStream({
945
+ resumable: false,
946
+ contentType: args.contentType,
947
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
948
+ metadata: { metadata: { sha256: args.sha256 } },
949
+ preconditionOpts: { ifGenerationMatch: 0 }
950
+ });
951
+ try {
952
+ if (args.signal) {
953
+ await pipeline(Readable.from(args.chunks), destination, { signal: args.signal });
954
+ } else {
955
+ await pipeline(Readable.from(args.chunks), destination);
956
+ }
957
+ return true;
958
+ } catch (error) {
959
+ if (isGcsVersionMismatch(error)) return false;
960
+ throw error;
961
+ }
962
+ },
271
963
  async headFile(file) {
272
964
  const [metadata] = await bucket.file(file.objectKey).getMetadata();
273
965
  return objectHead({
@@ -299,6 +991,33 @@ function createGcsObjectStorage(settings) {
299
991
  throw error;
300
992
  }
301
993
  },
994
+ async headObject(key) {
995
+ try {
996
+ const [metadata] = await bucket.file(key).getMetadata();
997
+ return objectHead({
998
+ contentLength: parseContentLength(metadata.size),
999
+ contentType: metadata.contentType,
1000
+ metadata: stringMetadata(metadata.metadata),
1001
+ versionToken: metadata.generation === void 0 ? void 0 : String(metadata.generation)
1002
+ });
1003
+ } catch (error) {
1004
+ if (isGcsNotFound(error)) return null;
1005
+ throw error;
1006
+ }
1007
+ },
1008
+ async getObjectRange(args) {
1009
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
1010
+ try {
1011
+ const [bytes] = await bucket.file(args.key, { generation: args.expectedVersionToken }).download({ start: args.start, end: args.endInclusive });
1012
+ return {
1013
+ bytes: exactRangeBytes(bytes, args.key, length),
1014
+ versionToken: args.expectedVersionToken
1015
+ };
1016
+ } catch (error) {
1017
+ if (isGcsNotFound(error) || isGcsVersionMismatch(error)) return null;
1018
+ throw error;
1019
+ }
1020
+ },
302
1021
  async getObjectBytes(key) {
303
1022
  try {
304
1023
  const [bytes] = await bucket.file(key).download();
@@ -318,6 +1037,9 @@ function createGcsObjectStorage(settings) {
318
1037
  function isGcsNotFound(error) {
319
1038
  return Boolean(error) && typeof error === "object" && error.code === 404;
320
1039
  }
1040
+ function isGcsVersionMismatch(error) {
1041
+ return Boolean(error) && typeof error === "object" && error.code === 412;
1042
+ }
321
1043
  function createAzureBlobObjectStorage(settings) {
322
1044
  const sharedKey = azureSharedKeyCredential(settings);
323
1045
  const requestServiceClient = settings.objectStorageAzureConnectionString ? BlobServiceClient.fromConnectionString(settings.objectStorageAzureConnectionString) : new BlobServiceClient(azureBlobServiceUrl(settings), sharedKey);
@@ -382,6 +1104,41 @@ function createAzureBlobObjectStorage(settings) {
382
1104
  ...args.sha256 ? { metadata: { sha256: args.sha256 } } : {}
383
1105
  });
384
1106
  },
1107
+ async putObjectIfAbsent(args) {
1108
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
1109
+ const body = Buffer.from(args.body);
1110
+ try {
1111
+ await blobClient.upload(body, body.byteLength, {
1112
+ blobHTTPHeaders: { blobContentType: args.contentType },
1113
+ metadata: { sha256: args.sha256 },
1114
+ conditions: { ifNoneMatch: "*" }
1115
+ });
1116
+ return true;
1117
+ } catch (error) {
1118
+ if (isAzureVersionMismatch(error)) return false;
1119
+ throw error;
1120
+ }
1121
+ },
1122
+ async putObjectStreamIfAbsent(args) {
1123
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
1124
+ try {
1125
+ await blobClient.uploadStream(
1126
+ Readable.from(args.chunks),
1127
+ INTERNAL_STREAM_BUFFER_BYTES,
1128
+ INTERNAL_STREAM_CONCURRENCY,
1129
+ {
1130
+ blobHTTPHeaders: { blobContentType: args.contentType },
1131
+ metadata: { sha256: args.sha256 },
1132
+ conditions: { ifNoneMatch: "*" },
1133
+ ...args.signal ? { abortSignal: args.signal } : {}
1134
+ }
1135
+ );
1136
+ return true;
1137
+ } catch (error) {
1138
+ if (isAzureVersionMismatch(error)) return false;
1139
+ throw error;
1140
+ }
1141
+ },
385
1142
  async headFile(file) {
386
1143
  return azureHeadToObjectHead(
387
1144
  await requestContainerClient.getBlobClient(file.objectKey).getProperties()
@@ -414,6 +1171,34 @@ function createAzureBlobObjectStorage(settings) {
414
1171
  throw error;
415
1172
  }
416
1173
  },
1174
+ async headObject(key) {
1175
+ try {
1176
+ const properties = await requestContainerClient.getBlobClient(key).getProperties();
1177
+ return objectHead({
1178
+ contentLength: properties.contentLength,
1179
+ contentType: properties.contentType,
1180
+ metadata: properties.metadata,
1181
+ versionToken: properties.etag
1182
+ });
1183
+ } catch (error) {
1184
+ if (isAzureNotFound(error)) return null;
1185
+ throw error;
1186
+ }
1187
+ },
1188
+ async getObjectRange(args) {
1189
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
1190
+ try {
1191
+ const download = await requestContainerClient.getBlobClient(args.key).download(args.start, length, { conditions: { ifMatch: args.expectedVersionToken } });
1192
+ if (!download.etag || download.etag !== args.expectedVersionToken) return null;
1193
+ return {
1194
+ bytes: await azureDownloadToBoundedBytes(download, args.key, length),
1195
+ versionToken: download.etag
1196
+ };
1197
+ } catch (error) {
1198
+ if (isAzureNotFound(error) || isAzureVersionMismatch(error)) return null;
1199
+ throw error;
1200
+ }
1201
+ },
417
1202
  async getObjectBytes(key) {
418
1203
  try {
419
1204
  const download = await requestContainerClient.getBlobClient(key).download();
@@ -434,6 +1219,9 @@ function createAzureBlobObjectStorage(settings) {
434
1219
  function isAzureNotFound(error) {
435
1220
  return Boolean(error) && typeof error === "object" && error.statusCode === 404;
436
1221
  }
1222
+ function isAzureVersionMismatch(error) {
1223
+ return Boolean(error) && typeof error === "object" && error.statusCode === 412;
1224
+ }
437
1225
  function azureSharedKeyCredential(settings) {
438
1226
  if (settings.objectStorageAzureConnectionString) {
439
1227
  const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);
@@ -557,6 +1345,16 @@ function assertFileByteRange(file, range) {
557
1345
  }
558
1346
  return length;
559
1347
  }
1348
+ function assertRawObjectByteRange(key, start, endInclusive) {
1349
+ if (typeof key !== "string" || key.length < 1 || key.length > 2048 || !Number.isSafeInteger(start) || !Number.isSafeInteger(endInclusive) || start < 0 || endInclusive < start) {
1350
+ throw new RangeError("Invalid raw object byte range");
1351
+ }
1352
+ const length = endInclusive - start + 1;
1353
+ if (length > RETAINED_OUTPUT_MAX_PAGE_BYTES) {
1354
+ throw new RangeError("Raw object byte range exceeds the bounded page limit");
1355
+ }
1356
+ return length;
1357
+ }
560
1358
  function exactRangeBytes(bytes, objectKey, expectedBytes) {
561
1359
  if (bytes.byteLength !== expectedBytes) {
562
1360
  throw new Error(
@@ -569,17 +1367,25 @@ function objectHead(input) {
569
1367
  return {
570
1368
  ...input.contentLength !== void 0 ? { ContentLength: input.contentLength } : {},
571
1369
  ...input.contentType !== void 0 ? { ContentType: input.contentType } : {},
572
- ...input.metadata !== void 0 ? { Metadata: input.metadata } : {}
1370
+ ...input.metadata !== void 0 ? { Metadata: input.metadata } : {},
1371
+ ...input.versionToken !== void 0 ? { VersionToken: input.versionToken } : {}
573
1372
  };
574
1373
  }
575
1374
  function bytesToDataUrl(bytes, contentType) {
576
1375
  return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`;
577
1376
  }
578
1377
  export {
1378
+ BoundedObjectReadError,
1379
+ BoundedObjectWriteError,
1380
+ DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES,
579
1381
  DOWNLOAD_URL_TTL_SECONDS,
1382
+ MAX_BOUNDED_OBJECT_CHUNK_BYTES,
580
1383
  MAX_SINGLE_PUT_SIZE_BYTES,
581
1384
  UPLOAD_URL_TTL_SECONDS,
582
1385
  bytesToDataUrl,
583
- createObjectStorage
1386
+ createBoundedImmutableObjectWritePort,
1387
+ createBoundedObjectReadPort,
1388
+ createObjectStorage,
1389
+ createObjectStorageBoundedPorts
584
1390
  };
585
1391
  //# sourceMappingURL=index.js.map