@ardrive/turbo-sdk 1.42.0 → 1.43.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/lib/cjs/cli/cli.js +97 -0
  2. package/lib/cjs/cli/commands/arns.js +475 -0
  3. package/lib/cjs/cli/commands/freeStatus.js +47 -0
  4. package/lib/cjs/cli/commands/index.js +3 -0
  5. package/lib/cjs/cli/commands/paymentHistory.js +25 -0
  6. package/lib/cjs/cli/options.js +202 -1
  7. package/lib/cjs/common/arnsActions.js +125 -0
  8. package/lib/cjs/common/chunked.js +51 -1
  9. package/lib/cjs/common/folderIndex.js +401 -0
  10. package/lib/cjs/common/http.js +40 -6
  11. package/lib/cjs/common/index.js +7 -0
  12. package/lib/cjs/common/payment.js +548 -2
  13. package/lib/cjs/common/signer.js +27 -6
  14. package/lib/cjs/common/token/index.js +0 -11
  15. package/lib/cjs/common/turbo.js +162 -0
  16. package/lib/cjs/common/upload.js +314 -31
  17. package/lib/cjs/node/folderIndex.js +132 -0
  18. package/lib/cjs/node/index.js +1 -0
  19. package/lib/cjs/node/upload.js +7 -0
  20. package/lib/cjs/types.js +53 -3
  21. package/lib/cjs/utils/common.js +0 -4
  22. package/lib/cjs/utils/errors.js +38 -1
  23. package/lib/cjs/utils/folderIndex.js +88 -0
  24. package/lib/cjs/utils/uuid.js +31 -0
  25. package/lib/cjs/web/signer.js +4 -2
  26. package/lib/esm/cli/cli.js +99 -2
  27. package/lib/esm/cli/commands/arns.js +450 -0
  28. package/lib/esm/cli/commands/freeStatus.js +44 -0
  29. package/lib/esm/cli/commands/index.js +3 -0
  30. package/lib/esm/cli/commands/paymentHistory.js +22 -0
  31. package/lib/esm/cli/options.js +201 -0
  32. package/lib/esm/common/arnsActions.js +114 -0
  33. package/lib/esm/common/chunked.js +51 -1
  34. package/lib/esm/common/folderIndex.js +396 -0
  35. package/lib/esm/common/http.js +39 -5
  36. package/lib/esm/common/index.js +7 -0
  37. package/lib/esm/common/payment.js +548 -2
  38. package/lib/esm/common/signer.js +28 -7
  39. package/lib/esm/common/token/index.js +1 -12
  40. package/lib/esm/common/turbo.js +162 -0
  41. package/lib/esm/common/upload.js +316 -33
  42. package/lib/esm/node/folderIndex.js +129 -0
  43. package/lib/esm/node/index.js +1 -0
  44. package/lib/esm/node/upload.js +7 -0
  45. package/lib/esm/types.js +52 -2
  46. package/lib/esm/utils/common.js +0 -4
  47. package/lib/esm/utils/errors.js +35 -0
  48. package/lib/esm/utils/folderIndex.js +81 -0
  49. package/lib/esm/utils/uuid.js +28 -0
  50. package/lib/esm/web/signer.js +4 -2
  51. package/lib/types/cli/commands/arns.d.ts +147 -0
  52. package/lib/types/cli/commands/arns.d.ts.map +1 -0
  53. package/lib/types/cli/commands/freeStatus.d.ts +3 -0
  54. package/lib/types/cli/commands/freeStatus.d.ts.map +1 -0
  55. package/lib/types/cli/commands/index.d.ts +3 -0
  56. package/lib/types/cli/commands/index.d.ts.map +1 -1
  57. package/lib/types/cli/commands/paymentHistory.d.ts +18 -0
  58. package/lib/types/cli/commands/paymentHistory.d.ts.map +1 -0
  59. package/lib/types/cli/options.d.ts +423 -0
  60. package/lib/types/cli/options.d.ts.map +1 -1
  61. package/lib/types/cli/types.d.ts +78 -0
  62. package/lib/types/cli/types.d.ts.map +1 -1
  63. package/lib/types/common/arnsActions.d.ts +58 -0
  64. package/lib/types/common/arnsActions.d.ts.map +1 -0
  65. package/lib/types/common/chunked.d.ts +47 -2
  66. package/lib/types/common/chunked.d.ts.map +1 -1
  67. package/lib/types/common/folderIndex.d.ts +54 -0
  68. package/lib/types/common/folderIndex.d.ts.map +1 -0
  69. package/lib/types/common/http.d.ts +25 -2
  70. package/lib/types/common/http.d.ts.map +1 -1
  71. package/lib/types/common/index.d.ts +3 -0
  72. package/lib/types/common/index.d.ts.map +1 -1
  73. package/lib/types/common/payment.d.ts +291 -1
  74. package/lib/types/common/payment.d.ts.map +1 -1
  75. package/lib/types/common/signer.d.ts +12 -6
  76. package/lib/types/common/signer.d.ts.map +1 -1
  77. package/lib/types/common/token/index.d.ts.map +1 -1
  78. package/lib/types/common/turbo.d.ts +183 -1
  79. package/lib/types/common/turbo.d.ts.map +1 -1
  80. package/lib/types/common/upload.d.ts +41 -0
  81. package/lib/types/common/upload.d.ts.map +1 -1
  82. package/lib/types/node/folderIndex.d.ts +28 -0
  83. package/lib/types/node/folderIndex.d.ts.map +1 -0
  84. package/lib/types/node/index.d.ts +1 -0
  85. package/lib/types/node/index.d.ts.map +1 -1
  86. package/lib/types/node/upload.d.ts +1 -0
  87. package/lib/types/node/upload.d.ts.map +1 -1
  88. package/lib/types/types.d.ts +694 -3
  89. package/lib/types/types.d.ts.map +1 -1
  90. package/lib/types/utils/common.d.ts.map +1 -1
  91. package/lib/types/utils/errors.d.ts +29 -0
  92. package/lib/types/utils/errors.d.ts.map +1 -1
  93. package/lib/types/utils/folderIndex.d.ts +47 -0
  94. package/lib/types/utils/folderIndex.d.ts.map +1 -0
  95. package/lib/types/utils/uuid.d.ts +7 -0
  96. package/lib/types/utils/uuid.d.ts.map +1 -0
  97. package/lib/types/web/signer.d.ts +1 -1
  98. package/lib/types/web/signer.d.ts.map +1 -1
  99. package/package.json +1 -1
@@ -22,6 +22,7 @@ const types_js_1 = require("../types.js");
22
22
  const common_js_1 = require("../utils/common.js");
23
23
  const errors_js_1 = require("../utils/errors.js");
24
24
  const errors_js_2 = require("../utils/errors.js");
25
+ const folderIndex_js_1 = require("../utils/folderIndex.js");
25
26
  const chunked_js_1 = require("./chunked.js");
26
27
  const events_js_1 = require("./events.js");
27
28
  const http_js_1 = require("./http.js");
@@ -123,7 +124,9 @@ class TurboUnauthenticatedUploadService {
123
124
  };
124
125
  return this.httpService.post({
125
126
  data: dataBuffer,
126
- endpoint: '/x402/data-item/unsigned',
127
+ // Only reached when no signer was supplied; the x402 path recomputes this
128
+ // from `unsignedData`. Both must name the same route.
129
+ endpoint: http_js_2.x402UploadEndpoints.unsigned,
127
130
  signal,
128
131
  headers: tags !== undefined
129
132
  ? { 'x-data-item-tags': JSON.stringify(tags) }
@@ -142,7 +145,6 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
142
145
  'solana',
143
146
  'base-eth',
144
147
  'base-usdc',
145
- 'base-ario',
146
148
  ];
147
149
  this.signer = signer;
148
150
  this.paymentService = paymentService;
@@ -224,9 +226,6 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
224
226
  !this.x402EnabledTokens.includes(this.token)) {
225
227
  throw new Error('x402 uploads are not supported for token: ' + this.token);
226
228
  }
227
- if (params.chunkingMode === 'force' && fundingMode instanceof types_js_1.X402Funding) {
228
- throw new Error("Chunking mode 'force' is not supported when x402 is enabled");
229
- }
230
229
  this.logger.debug('Starting file upload', { params });
231
230
  let retries = 0;
232
231
  const maxRetries = this.retryConfig.retries ?? 3;
@@ -262,6 +261,19 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
262
261
  // this result due to the wrapped retry logic of this method.
263
262
  try {
264
263
  const { chunkByteCount, maxChunkConcurrency } = params;
264
+ // Built here rather than after the chunked branch so BOTH paths can
265
+ // pay with it. Chunked x402 uploads used to be impossible — the
266
+ // bundler had no way to charge for a multipart upload — so this branch
267
+ // deliberately fell back to a single request, which capped x402 at the
268
+ // single-item limit no matter how the client chunked. The bundler now
269
+ // settles at create, so the fallback is no longer needed.
270
+ const x402Options = fundingMode instanceof types_js_1.X402Funding
271
+ ? {
272
+ signer: fundingMode.signer ??
273
+ (await (0, signer_js_1.makeX402Signer)(this.signer.signer)),
274
+ maxMUSDCAmount: fundingMode.maxMUSDCAmount,
275
+ }
276
+ : undefined;
265
277
  const chunkedUploader = new chunked_js_1.ChunkedUploader({
266
278
  http: this.httpService,
267
279
  token: this.token,
@@ -271,9 +283,17 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
271
283
  dataItemByteCount: dataItemSizeFactory(),
272
284
  chunkingMode: params.chunkingMode,
273
285
  maxFinalizeMs: params.maxFinalizeMs,
286
+ ...(x402Options ? { x402: x402Options } : {}),
287
+ ...(x402Options
288
+ ? {
289
+ x402RefundIdentity: {
290
+ address: await this.signer.getNativeAddress(),
291
+ signatureType: this.signer.signer.signatureType,
292
+ },
293
+ }
294
+ : {}),
274
295
  });
275
- if (chunkedUploader.shouldUseChunkUploader &&
276
- !(fundingMode instanceof types_js_1.X402Funding)) {
296
+ if (chunkedUploader.shouldUseChunkUploader) {
277
297
  const response = await chunkedUploader.upload({
278
298
  dataItemStreamFactory,
279
299
  dataItemSizeFactory,
@@ -283,13 +303,6 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
283
303
  });
284
304
  return { ...response, cryptoFundResult };
285
305
  }
286
- const x402Options = fundingMode instanceof types_js_1.X402Funding
287
- ? {
288
- signer: fundingMode.signer ??
289
- (await (0, signer_js_1.makeX402Signer)(this.signer.signer)),
290
- maxMUSDCAmount: fundingMode.maxMUSDCAmount,
291
- }
292
- : undefined;
293
306
  const response = await this.uploadSignedDataItem({
294
307
  dataItemStreamFactory,
295
308
  dataItemSizeFactory,
@@ -358,6 +371,214 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
358
371
  };
359
372
  return manifest;
360
373
  }
374
+ /**
375
+ * The sha-256 of a file's bytes, as lowercase hex. The key of a
376
+ * {@link TurboFolderUploadIndex}.
377
+ *
378
+ * This default consumes the file's stream and digests it with the platform
379
+ * WebCrypto implementation, which serves the browser. NodeJS overrides it
380
+ * with a streaming digest so a large file is never held in memory.
381
+ */
382
+ async computeContentHash(file) {
383
+ const stream = this.getFileStreamForFile(file);
384
+ const chunks = [];
385
+ if ('getReader' in stream) {
386
+ const reader = stream.getReader();
387
+ for (;;) {
388
+ const { done, value } = await reader.read();
389
+ if (done)
390
+ break;
391
+ chunks.push(value);
392
+ }
393
+ }
394
+ else {
395
+ for await (const chunk of stream) {
396
+ chunks.push(new Uint8Array(chunk));
397
+ }
398
+ }
399
+ const digest = await crypto.subtle.digest('SHA-256', Buffer.concat(chunks));
400
+ return Buffer.from(digest).toString('hex');
401
+ }
402
+ /**
403
+ * The exact tag set a folder upload writes for one file. Shared by the
404
+ * planner and the uploader, so that the tags a folder index key is computed
405
+ * from are, without question, the tags the data item ends up carrying.
406
+ */
407
+ folderFileTags({ file, dataItemOpts, contentHash, hashTagName = folderIndex_js_1.contentHashTagName, }) {
408
+ return [
409
+ ...(dataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type' &&
410
+ // Only stripped when this upload is going to write its own. Without a
411
+ // folder index, a caller's File-SHA256 tag is theirs to keep.
412
+ (contentHash === undefined || tag.name !== hashTagName)) ?? []),
413
+ {
414
+ name: 'Content-Type',
415
+ value: this.getContentType(file, dataItemOpts),
416
+ },
417
+ ...(contentHash !== undefined
418
+ ? [{ name: hashTagName, value: contentHash }]
419
+ : []),
420
+ ];
421
+ }
422
+ /**
423
+ * A folder index is a cache, so a layer that is unreachable must mean a miss
424
+ * and not an aborted deploy. The write side is forgiving for the same reason.
425
+ */
426
+ async readFolderIndex(folderIndex, keys, signal) {
427
+ const knownIds = new Map();
428
+ const unresolved = [];
429
+ for (const key of keys) {
430
+ let id;
431
+ try {
432
+ id = await folderIndex.get(key);
433
+ }
434
+ catch (error) {
435
+ this.logger.error('Failed to read from folder index', error);
436
+ }
437
+ if (id !== undefined && (0, common_js_1.isValidArweaveBase64URL)(id)) {
438
+ knownIds.set(key, id);
439
+ }
440
+ else {
441
+ unresolved.push(key);
442
+ }
443
+ }
444
+ if (unresolved.length > 0 && folderIndex.resolve !== undefined) {
445
+ try {
446
+ const resolved = await folderIndex.resolve(unresolved, { signal });
447
+ for (const [key, id] of Object.entries(resolved)) {
448
+ if ((0, common_js_1.isValidArweaveBase64URL)(id)) {
449
+ knownIds.set(key, id);
450
+ }
451
+ }
452
+ }
453
+ catch (error) {
454
+ this.logger.error('Failed to resolve from folder index', error);
455
+ }
456
+ }
457
+ return knownIds;
458
+ }
459
+ /**
460
+ * Hashes every file and derives its folder index key, then works out which of
461
+ * those keys already have a data item id -- from the index, or from an
462
+ * identical file earlier in this same folder.
463
+ */
464
+ async planFolderIndex({ files, folderIndex, dataItemOpts, limit, signal, }) {
465
+ const contentHashes = new Map();
466
+ const itemKeys = new Map();
467
+ await Promise.all(files.map((file) => limit(async () => {
468
+ let contentHash;
469
+ try {
470
+ contentHash = await this.computeContentHash(file);
471
+ }
472
+ catch (error) {
473
+ // A file that cannot be read is left un-keyed and falls through to
474
+ // the per-file upload path, which reports it through `errors` under
475
+ // `throwOnFailure`. Rejecting here instead would abort the whole
476
+ // folder before any upload starts, so passing a folder index would
477
+ // silently change the failure contract.
478
+ this.logger.error('Failed to hash file for the folder index', {
479
+ file: this.getFileName(file),
480
+ error,
481
+ });
482
+ return;
483
+ }
484
+ contentHashes.set(file, contentHash);
485
+ itemKeys.set(file, await (0, folderIndex_js_1.folderIndexKey)({
486
+ contentHash,
487
+ tags: this.folderFileTags({
488
+ file,
489
+ dataItemOpts,
490
+ contentHash,
491
+ hashTagName: folderIndex.hashTagName,
492
+ }),
493
+ }));
494
+ })));
495
+ const knownIds = await this.readFolderIndex(folderIndex, [...new Set(itemKeys.values())], signal);
496
+ // Two files that would produce the same data item share a single upload, so
497
+ // the plan deduplicates within the folder as well as against the index.
498
+ const claimed = new Set();
499
+ const filesToUpload = files.filter((file) => {
500
+ const key = itemKeys.get(file);
501
+ // An un-keyed file could not be hashed. It is never a reuse candidate and
502
+ // must not share the dedup slot with the next un-keyed file, so it skips
503
+ // the claimed set entirely and always uploads.
504
+ if (key === undefined) {
505
+ return true;
506
+ }
507
+ if (knownIds.has(key) || claimed.has(key)) {
508
+ return false;
509
+ }
510
+ claimed.add(key);
511
+ return true;
512
+ });
513
+ this.logger.debug('Planned folder index', {
514
+ folderIndex: folderIndex.name,
515
+ totalFiles: files.length,
516
+ filesToUpload: filesToUpload.length,
517
+ });
518
+ await this.warnOnStaleTagMisses({ folderIndex, itemKeys, knownIds });
519
+ return { contentHashes, itemKeys, knownIds, filesToUpload };
520
+ }
521
+ /**
522
+ * A key covers the tags on a file as well as its bytes, so a tag in
523
+ * `dataItemOpts` that changes between deploys re-uploads the whole folder at
524
+ * full price. That is the right answer -- a reused data item is never one
525
+ * this call would not have made -- but on its own it is a silent cost cliff:
526
+ * a successful deploy, a full bill, and nothing saying why.
527
+ *
528
+ * The signature of that mistake is exact, and it is already in hand. A key is
529
+ * `<bytes>.<tags>`, so a file whose *bytes half* the index knows under some
530
+ * other tags half is a file whose content is already paid for and whose tags
531
+ * moved. Nothing else produces that: a folder the index has never seen has
532
+ * unknown bytes, and a layer that could not be reached reports nothing known.
533
+ * It also catches one file in a hundred, not just all of them.
534
+ */
535
+ async warnOnStaleTagMisses({ folderIndex, itemKeys, knownIds, }) {
536
+ if (folderIndex.knownContentHashes === undefined) {
537
+ return;
538
+ }
539
+ // Counted in files, not in keys: the message is about a bill, and the bill
540
+ // is per file. Two files that share a key are one upload but the reader is
541
+ // looking for their own file count.
542
+ const filesPerKey = new Map();
543
+ for (const key of itemKeys.values()) {
544
+ filesPerKey.set(key, (filesPerKey.get(key) ?? 0) + 1);
545
+ }
546
+ const missed = [...filesPerKey.keys()].filter((key) => !knownIds.has(key));
547
+ if (missed.length === 0) {
548
+ return;
549
+ }
550
+ const filesFor = (keys) => keys.reduce((count, key) => count + (filesPerKey.get(key) ?? 0), 0);
551
+ const missedFiles = filesFor(missed);
552
+ const missedByContentHash = new Map();
553
+ for (const key of missed) {
554
+ const contentHash = (0, folderIndex_js_1.contentHashFromFolderIndexKey)(key);
555
+ missedByContentHash.set(contentHash, [
556
+ ...(missedByContentHash.get(contentHash) ?? []),
557
+ key,
558
+ ]);
559
+ }
560
+ let staleTagged = [];
561
+ try {
562
+ staleTagged =
563
+ (await folderIndex.knownContentHashes([
564
+ ...missedByContentHash.keys(),
565
+ ])) ?? [];
566
+ }
567
+ catch (error) {
568
+ this.logger.error('Failed to read from folder index', error);
569
+ return;
570
+ }
571
+ const affected = staleTagged.reduce((count, contentHash) => count + filesFor(missedByContentHash.get(contentHash) ?? []), 0);
572
+ if (affected === 0) {
573
+ return;
574
+ }
575
+ this.logger.warn(`${affected} of the ${missedFiles} file(s) this run is about to upload are already on Arweave byte for byte, under a different set of tags. ` +
576
+ 'Their content has not changed but their tags have, so they are being paid for again. A folder index key covers the tags ' +
577
+ 'on a file as well as its bytes. That is usually a tag in dataItemOpts whose value changes between deploys -- a commit ' +
578
+ 'sha, a build number, a timestamp -- in which case move it to manifestDataItemOpts rather than paying for these files ' +
579
+ 'again. It can also be a file that kept its content but changed its Content-Type, through a rename or a new extension, ' +
580
+ 'which is expected and costs one upload.');
581
+ }
361
582
  getContentType(file, dataItemOpts) {
362
583
  const userDefinedContentType = dataItemOpts?.tags?.find((tag) => tag.name === 'Content-Type')?.value;
363
584
  if (userDefinedContentType !== undefined) {
@@ -367,7 +588,7 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
367
588
  }
368
589
  async uploadFolder(params) {
369
590
  this.logger.debug('Uploading folder...', { params });
370
- const { dataItemOpts, signal, manifestOptions = {}, maxConcurrentUploads = 1, throwOnFailure = true, maxChunkConcurrency, chunkByteCount, chunkingMode, fundingMode = new types_js_1.ExistingBalanceFunding(), maxFinalizeMs, events = {}, } = params;
591
+ const { dataItemOpts, manifestDataItemOpts = dataItemOpts, folderIndex, signal, manifestOptions = {}, maxConcurrentUploads = 1, throwOnFailure = true, maxChunkConcurrency, chunkByteCount, chunkingMode, fundingMode = new types_js_1.ExistingBalanceFunding(), maxFinalizeMs, events = {}, } = params;
371
592
  const { disableManifest, indexFile, fallbackFile } = manifestOptions;
372
593
  // Create event emitter from events parameter
373
594
  const emitter = new events_js_1.TurboEventEmitter(events);
@@ -376,15 +597,37 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
376
597
  fileResponses: [],
377
598
  };
378
599
  const errors = [];
600
+ const limit = (0, plimit_lit_1.pLimit)(maxConcurrentUploads);
379
601
  // Get files and calculate total bytes upfront for progress tracking
380
602
  const files = await this.getFiles(params);
381
- const totalFiles = files.length;
603
+ // With an index, only the files whose bytes are not already on Arweave are
604
+ // signed, uploaded and paid for. Progress totals cover that subset, since
605
+ // it is all this call actually does.
606
+ let contentHashes = new Map();
607
+ let itemKeys = new Map();
608
+ let knownIds = new Map();
609
+ let filesToUpload = files;
610
+ if (folderIndex !== undefined) {
611
+ ({ contentHashes, itemKeys, knownIds, filesToUpload } =
612
+ await this.planFolderIndex({
613
+ files,
614
+ folderIndex,
615
+ dataItemOpts,
616
+ limit,
617
+ signal,
618
+ }));
619
+ }
620
+ const totalFiles = filesToUpload.length;
382
621
  let totalBytes = 0;
622
+ let folderBytes = 0;
383
623
  const fileSizes = new Map();
384
624
  files.forEach((file) => {
385
625
  const size = this.getFileSize(file);
386
626
  fileSizes.set(file, size);
387
- totalBytes += size;
627
+ folderBytes += size;
628
+ });
629
+ filesToUpload.forEach((file) => {
630
+ totalBytes += fileSizes.get(file) ?? 0;
388
631
  });
389
632
  // Track progress across all files
390
633
  let processedFiles = 0;
@@ -399,13 +642,15 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
399
642
  fileIndex,
400
643
  totalFiles,
401
644
  });
402
- const contentType = this.getContentType(file, dataItemOpts);
645
+ const contentHash = contentHashes.get(file);
403
646
  const dataItemOptsWithContentType = {
404
647
  ...dataItemOpts,
405
- tags: [
406
- ...(dataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type') ?? []),
407
- { name: 'Content-Type', value: contentType },
408
- ],
648
+ tags: this.folderFileTags({
649
+ file,
650
+ dataItemOpts,
651
+ contentHash,
652
+ hashTagName: folderIndex?.hashTagName,
653
+ }),
409
654
  };
410
655
  try {
411
656
  const result = await this.uploadFile({
@@ -450,8 +695,24 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
450
695
  },
451
696
  fundingMode,
452
697
  });
453
- const relativePath = this.getRelativePath(file, params);
454
- paths[relativePath] = { id: result.id };
698
+ const itemKey = itemKeys.get(file);
699
+ if (itemKey !== undefined) {
700
+ // Recorded before anything else can fail, so a run that is killed
701
+ // part way through never loses a file it has already paid for.
702
+ knownIds.set(itemKey, result.id);
703
+ try {
704
+ await folderIndex?.set(itemKey, result.id);
705
+ }
706
+ catch (error) {
707
+ // A failed index write costs the next run a re-upload; it must not
708
+ // fail this one, which has already paid for and landed the bytes.
709
+ this.logger.error('Failed to write to folder index', error);
710
+ }
711
+ }
712
+ else {
713
+ const relativePath = this.getRelativePath(file, params);
714
+ paths[relativePath] = { id: result.id };
715
+ }
455
716
  response.fileResponses.push(result);
456
717
  // Update processed counts after file completes
457
718
  processedFiles++;
@@ -487,10 +748,9 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
487
748
  errors.push(error);
488
749
  }
489
750
  };
490
- const limit = (0, plimit_lit_1.pLimit)(maxConcurrentUploads);
491
751
  let cryptoFundResult;
492
752
  if (fundingMode instanceof types_js_1.OnDemandFunding) {
493
- const totalByteCount = files.reduce((acc, file) => {
753
+ const totalByteCount = filesToUpload.reduce((acc, file) => {
494
754
  return acc + this.getFileSize(file) + 1200; // allow extra per file for ANS-104 headers
495
755
  }, 0);
496
756
  cryptoFundResult = await this.onDemand({
@@ -498,15 +758,36 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
498
758
  onDemandFunding: fundingMode,
499
759
  });
500
760
  }
501
- await Promise.all(files.map((file, index) => limit(() => uploadFile(file, index))));
761
+ await Promise.all(filesToUpload.map((file, index) => limit(() => uploadFile(file, index))));
502
762
  this.logger.debug('Finished uploading files', {
503
- numFiles: files.length,
763
+ numFiles: filesToUpload.length,
504
764
  numErrors: errors.length,
505
765
  results: response.fileResponses,
506
766
  });
507
767
  if (errors.length > 0) {
508
768
  response.errors = errors;
509
769
  }
770
+ if (folderIndex !== undefined) {
771
+ // Built in folder order rather than in completion order, so an unchanged
772
+ // folder produces byte identical manifest bytes -- and therefore an
773
+ // identical manifest data item id -- run after run.
774
+ for (const file of files) {
775
+ const id = knownIds.get(itemKeys.get(file));
776
+ if (id !== undefined) {
777
+ paths[this.getRelativePath(file, params)] = { id };
778
+ }
779
+ }
780
+ response.folderIndexSummary = {
781
+ totalFiles: files.length,
782
+ totalBytes: folderBytes,
783
+ // What landed, not what was planned. With throwOnFailure: false a file
784
+ // can be planned, paid for nothing, and never arrive.
785
+ uploadedFiles: processedFiles,
786
+ uploadedBytes: processedBytes,
787
+ reusedFiles: files.length - filesToUpload.length,
788
+ reusedBytes: folderBytes - totalBytes,
789
+ };
790
+ }
510
791
  if (disableManifest) {
511
792
  emitter.emit('folder-success');
512
793
  return response;
@@ -525,8 +806,7 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
525
806
  fallbackFile,
526
807
  });
527
808
  const tagsWithManifestContentType = [
528
- ...(dataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type') ??
529
- []),
809
+ ...(manifestDataItemOpts?.tags?.filter((tag) => tag.name !== 'Content-Type') ?? []),
530
810
  { name: 'Content-Type', value: 'application/x.arweave-manifest+json' },
531
811
  ];
532
812
  const manifestBuffer = Buffer.from(JSON.stringify(manifest));
@@ -536,7 +816,10 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
536
816
  fileStreamFactory: () => this.createManifestStream(manifestBuffer),
537
817
  fileSizeFactory: () => manifestBuffer.byteLength,
538
818
  signal,
539
- dataItemOpts: { ...dataItemOpts, tags: tagsWithManifestContentType },
819
+ dataItemOpts: {
820
+ ...manifestDataItemOpts,
821
+ tags: tagsWithManifestContentType,
822
+ },
540
823
  chunkByteCount,
541
824
  maxChunkConcurrency,
542
825
  maxFinalizeMs,
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createFileFolderIndex = createFileFolderIndex;
4
+ /**
5
+ * Copyright (C) 2022-2024 Permanent Data Solutions, Inc.
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */
19
+ const node_fs_1 = require("node:fs");
20
+ const node_path_1 = require("node:path");
21
+ const common_js_1 = require("../utils/common.js");
22
+ const folderIndex_js_1 = require("../utils/folderIndex.js");
23
+ const fileFolderIndexVersion = 2;
24
+ /**
25
+ * A local record of folder index key to data item id, for uploads on a machine
26
+ * that keeps its working directory between deploys.
27
+ *
28
+ * The file is an append-only log, one JSON record per line, compacted when it
29
+ * is loaded. That shape falls out of two requirements pulling against each
30
+ * other:
31
+ *
32
+ * - It has to be written after every single upload, not once at the end of the
33
+ * run. An upload that has been paid for but forgotten is money burnt, and a
34
+ * deploy killed part way through is the normal case rather than the
35
+ * exceptional one.
36
+ * - Rewriting the whole file per upload is quadratic. A first deploy or a
37
+ * migration of a few thousand files spends minutes and gigabytes of writes on
38
+ * nothing but bookkeeping.
39
+ *
40
+ * Appending one line is constant work, and it is *more* crash safe than a
41
+ * rewrite rather than less: a process killed mid write can only damage the last
42
+ * line, which is dropped on load, where a torn rewrite loses every id in the
43
+ * file. Compaction happens once per load, through a temp file and a rename, so
44
+ * the readable file is never the partial one.
45
+ *
46
+ * Malformed records are dropped. A hand edited index must never be able to
47
+ * publish a manifest pointing at nothing.
48
+ */
49
+ function createFileFolderIndex({ filePath, logger, }) {
50
+ const absolutePath = (0, node_path_1.resolve)(filePath);
51
+ const tempPath = `${absolutePath}.tmp`;
52
+ const map = new Map();
53
+ const record = (key, id) => `${JSON.stringify({ k: key, i: id })}\n`;
54
+ const header = `${JSON.stringify({ folderIndex: fileFolderIndexVersion })}\n`;
55
+ /** Rewrites the log as one record per live entry, atomically. */
56
+ const compact = () => {
57
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(absolutePath), { recursive: true });
58
+ const body = [...map]
59
+ // Code units, not `localeCompare`, for the same reason the key encoding
60
+ // avoids it: it is not a total order over distinct strings and depends on
61
+ // the host's ICU data. Only the file's readability rides on this one, but
62
+ // a sort that can call two distinct keys equal has no business here.
63
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
64
+ .map(([key, id]) => record(key, id))
65
+ .join('');
66
+ try {
67
+ (0, node_fs_1.writeFileSync)(tempPath, `${header}${body}`);
68
+ (0, node_fs_1.renameSync)(tempPath, absolutePath);
69
+ }
70
+ catch (error) {
71
+ (0, node_fs_1.rmSync)(tempPath, { force: true });
72
+ throw error;
73
+ }
74
+ };
75
+ let lineCount = 0;
76
+ if ((0, node_fs_1.existsSync)(absolutePath)) {
77
+ let malformed = 0;
78
+ // A line at a time, so a truncated tail costs one entry rather than all of
79
+ // them.
80
+ for (const line of (0, node_fs_1.readFileSync)(absolutePath, 'utf-8').split('\n')) {
81
+ if (line.length === 0) {
82
+ continue;
83
+ }
84
+ lineCount++;
85
+ try {
86
+ const parsed = JSON.parse(line);
87
+ if (parsed?.folderIndex !== undefined) {
88
+ continue; // header
89
+ }
90
+ if ((0, folderIndex_js_1.isValidFolderIndexKey)(parsed?.k) &&
91
+ (0, common_js_1.isValidArweaveBase64URL)(parsed?.i)) {
92
+ map.set(parsed.k, parsed.i); // last write wins
93
+ }
94
+ else {
95
+ malformed++;
96
+ }
97
+ }
98
+ catch {
99
+ malformed++;
100
+ }
101
+ }
102
+ if (malformed > 0) {
103
+ logger?.warn(`Dropped ${malformed} unreadable record(s) from the folder index at ${absolutePath}`);
104
+ }
105
+ // Only worth rewriting when the log has actually accumulated slack.
106
+ if (lineCount > map.size + 1) {
107
+ try {
108
+ compact();
109
+ }
110
+ catch (error) {
111
+ logger?.warn(`Could not compact the folder index at ${absolutePath}`, error);
112
+ }
113
+ }
114
+ }
115
+ return {
116
+ name: `file:${absolutePath}`,
117
+ get: (key) => map.get(key),
118
+ set: (key, id) => {
119
+ map.set(key, id);
120
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(absolutePath), { recursive: true });
121
+ if (!(0, node_fs_1.existsSync)(absolutePath)) {
122
+ (0, node_fs_1.writeFileSync)(absolutePath, header);
123
+ }
124
+ (0, node_fs_1.appendFileSync)(absolutePath, record(key, id));
125
+ },
126
+ knownContentHashes: (contentHashes) => {
127
+ const known = new Set([...map.keys()].map(folderIndex_js_1.contentHashFromFolderIndexKey));
128
+ return contentHashes.filter((contentHash) => known.has(contentHash));
129
+ },
130
+ entries: () => Object.fromEntries(map),
131
+ };
132
+ }
@@ -30,6 +30,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
30
30
  * limitations under the License.
31
31
  */
32
32
  __exportStar(require("./factory.js"), exports);
33
+ __exportStar(require("./folderIndex.js"), exports);
33
34
  __exportStar(require("./signer.js"), exports);
34
35
  __exportStar(require("./upload.js"), exports);
35
36
  __exportStar(require("../types.js"), exports);
@@ -18,6 +18,8 @@ exports.TurboAuthenticatedUploadService = void 0;
18
18
  */
19
19
  const fs_1 = require("fs");
20
20
  const mime_types_1 = require("mime-types");
21
+ const node_crypto_1 = require("node:crypto");
22
+ const promises_1 = require("node:stream/promises");
21
23
  const path_1 = require("path");
22
24
  const stream_1 = require("stream");
23
25
  const upload_js_1 = require("../common/upload.js");
@@ -87,5 +89,10 @@ class TurboAuthenticatedUploadService extends upload_js_1.TurboAuthenticatedBase
87
89
  createManifestStream(manifestBuffer) {
88
90
  return stream_1.Readable.from(manifestBuffer);
89
91
  }
92
+ async computeContentHash(file) {
93
+ const hash = (0, node_crypto_1.createHash)('sha256');
94
+ await (0, promises_1.pipeline)((0, fs_1.createReadStream)(file), hash);
95
+ return hash.digest('hex');
96
+ }
90
97
  }
91
98
  exports.TurboAuthenticatedUploadService = TurboAuthenticatedUploadService;