@ubercode/dcmtk 0.14.0 → 0.15.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.cjs CHANGED
@@ -713,6 +713,51 @@ var LineParser = class extends events.EventEmitter {
713
713
  }
714
714
  };
715
715
 
716
+ // src/tools/_toolError.ts
717
+ var MAX_ARGS_LENGTH = 200;
718
+ var MAX_STDERR_LENGTH = 500;
719
+ function truncate(value, maxLength) {
720
+ if (value.length <= maxLength) {
721
+ return value;
722
+ }
723
+ return `${value.substring(0, maxLength)}...`;
724
+ }
725
+ var ToolExecutionError = class extends Error {
726
+ constructor(message, details) {
727
+ super(message);
728
+ __publicField(this, "stdout");
729
+ __publicField(this, "stderr");
730
+ __publicField(this, "exitCode");
731
+ this.name = "ToolExecutionError";
732
+ this.stdout = details.stdout;
733
+ this.stderr = details.stderr;
734
+ this.exitCode = details.exitCode;
735
+ }
736
+ };
737
+ function createToolError(toolName, args, exitCode, stderr8, stdout = "") {
738
+ const argsStr = truncate(args.join(" "), MAX_ARGS_LENGTH);
739
+ const stderrStr = truncate(stderr8.trim(), MAX_STDERR_LENGTH);
740
+ const parts = [`${toolName} failed (exit code ${String(exitCode)})`];
741
+ if (argsStr.length > 0) {
742
+ parts.push(`args: ${argsStr}`);
743
+ }
744
+ if (stderrStr.length > 0) {
745
+ parts.push(`stderr: ${stderrStr}`);
746
+ }
747
+ return new ToolExecutionError(parts.join(" | "), { stdout, stderr: stderr8, exitCode });
748
+ }
749
+ function createValidationError(toolName, zodError) {
750
+ const parts = [];
751
+ for (let i = 0; i < zodError.issues.length; i++) {
752
+ const issue = zodError.issues[i];
753
+ if (issue === void 0) continue;
754
+ const path2 = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
755
+ parts.push(`${path2}: ${issue.message}`);
756
+ }
757
+ const detail = parts.length > 0 ? parts.join("; ") : "unknown validation error";
758
+ return new Error(`${toolName}: invalid options \u2014 ${detail}`);
759
+ }
760
+
716
761
  // src/dicom/vr.ts
717
762
  var VR = {
718
763
  /** Application Entity — 16 bytes max, leading/trailing spaces significant */
@@ -31136,39 +31181,6 @@ function resolveBinary(toolName) {
31136
31181
  return ok(path.join(pathResult.value, binaryName2));
31137
31182
  }
31138
31183
 
31139
- // src/tools/_toolError.ts
31140
- var MAX_ARGS_LENGTH = 200;
31141
- var MAX_STDERR_LENGTH = 500;
31142
- function truncate(value, maxLength) {
31143
- if (value.length <= maxLength) {
31144
- return value;
31145
- }
31146
- return `${value.substring(0, maxLength)}...`;
31147
- }
31148
- function createToolError(toolName, args, exitCode, stderr8) {
31149
- const argsStr = truncate(args.join(" "), MAX_ARGS_LENGTH);
31150
- const stderrStr = truncate(stderr8.trim(), MAX_STDERR_LENGTH);
31151
- const parts = [`${toolName} failed (exit code ${String(exitCode)})`];
31152
- if (argsStr.length > 0) {
31153
- parts.push(`args: ${argsStr}`);
31154
- }
31155
- if (stderrStr.length > 0) {
31156
- parts.push(`stderr: ${stderrStr}`);
31157
- }
31158
- return new Error(parts.join(" | "));
31159
- }
31160
- function createValidationError(toolName, zodError) {
31161
- const parts = [];
31162
- for (let i = 0; i < zodError.issues.length; i++) {
31163
- const issue = zodError.issues[i];
31164
- if (issue === void 0) continue;
31165
- const path2 = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
31166
- parts.push(`${path2}: ${issue.message}`);
31167
- }
31168
- const detail = parts.length > 0 ? parts.join("; ") : "unknown validation error";
31169
- return new Error(`${toolName}: invalid options \u2014 ${detail}`);
31170
- }
31171
-
31172
31184
  // src/tools/dcm2xml.ts
31173
31185
  var Dcm2xmlCharset = {
31174
31186
  /** Use UTF-8 encoding (default). */
@@ -33290,7 +33302,7 @@ async function dcmsend(options) {
33290
33302
  return err(result.error);
33291
33303
  }
33292
33304
  if (result.value.exitCode !== 0) {
33293
- return err(createToolError("dcmsend", args, result.value.exitCode, result.value.stderr));
33305
+ return err(createToolError("dcmsend", args, result.value.exitCode, result.value.stderr, result.value.stdout));
33294
33306
  }
33295
33307
  return ok({ success: true, stdout: result.value.stdout, stderr: result.value.stderr });
33296
33308
  }
@@ -33445,10 +33457,10 @@ async function storescu(options) {
33445
33457
  return err(result.error);
33446
33458
  }
33447
33459
  if (result.value.exitCode !== 0) {
33448
- return err(createToolError("storescu", args, result.value.exitCode, result.value.stderr));
33460
+ return err(createToolError("storescu", args, result.value.exitCode, result.value.stderr, result.value.stdout));
33449
33461
  }
33450
33462
  if (DIMSE_ERROR_PATTERN.test(result.value.stderr)) {
33451
- return err(createToolError("storescu", args, 0, result.value.stderr));
33463
+ return err(createToolError("storescu", args, 0, result.value.stderr, result.value.stdout));
33452
33464
  }
33453
33465
  return ok({ success: true, stdout: result.value.stdout, stderr: result.value.stderr });
33454
33466
  }
@@ -37341,8 +37353,14 @@ var SenderEngine = class {
37341
37353
  // Bucket state (bucket mode only)
37342
37354
  __publicField(this, "currentBucket", []);
37343
37355
  __publicField(this, "bucketTimer");
37356
+ // Abort wiring: stopController fires on stop(); combinedSignal also fires
37357
+ // when the externally provided config.signal aborts. Passed into the
37358
+ // executor and into delayInterruptible so stop() unblocks promptly.
37359
+ __publicField(this, "stopController", new AbortController());
37360
+ __publicField(this, "combinedSignal");
37344
37361
  this.config = config;
37345
37362
  this.effectiveMaxAssociations = config.configuredMaxAssociations;
37363
+ this.combinedSignal = combineSignals(this.stopController.signal, config.signal);
37346
37364
  }
37347
37365
  // -----------------------------------------------------------------------
37348
37366
  // Public API
@@ -37368,6 +37386,7 @@ var SenderEngine = class {
37368
37386
  async stop() {
37369
37387
  if (this.isStopped) return;
37370
37388
  this.isStopped = true;
37389
+ this.stopController.abort();
37371
37390
  this.clearBucketTimer();
37372
37391
  this.rejectBucket(`${this.config.senderName}: sender stopped`);
37373
37392
  this.rejectQueue(`${this.config.senderName}: sender stopped`);
@@ -37484,18 +37503,48 @@ var SenderEngine = class {
37484
37503
  }
37485
37504
  };
37486
37505
  }
37487
- /** Flushes the current bucket: combines all files, dispatches as one send. */
37506
+ /**
37507
+ * Flushes the current bucket. Entries with the same bucketParamsKey merge
37508
+ * into one underlying call; differing keys split into separate calls,
37509
+ * preserving first-occurrence order. Each group emits its own
37510
+ * BUCKET_FLUSHED. If the number of groups exceeds available capacity,
37511
+ * the surplus is queued and drains as capacity frees.
37512
+ */
37488
37513
  flushBucketInternal(reason) {
37489
37514
  if (this.currentBucket.length === 0) return;
37490
37515
  const entries = [...this.currentBucket];
37491
37516
  this.currentBucket = [];
37492
- const bucketEntry = this.mergeBucketEntries(entries);
37493
- this.config.emitters.emitBucketFlushed({ fileCount: bucketEntry.files.length, reason });
37494
- if (this.activeAssociations < this.effectiveMaxAssociations) {
37495
- void this.executeEntry(bucketEntry);
37496
- } else {
37497
- this.queue.push(bucketEntry);
37517
+ const groups = this.groupBucketByParams(entries);
37518
+ for (const group of groups) {
37519
+ const bucketEntry = this.mergeBucketEntries(group);
37520
+ this.config.emitters.emitBucketFlushed({ fileCount: bucketEntry.files.length, reason });
37521
+ if (this.activeAssociations < this.effectiveMaxAssociations) {
37522
+ void this.executeEntry(bucketEntry);
37523
+ } else {
37524
+ this.queue.push(bucketEntry);
37525
+ }
37526
+ }
37527
+ }
37528
+ /**
37529
+ * Groups bucket entries by bucketParamsKey while preserving first-occurrence
37530
+ * order. Without a key function, returns a single group containing every
37531
+ * entry (current behavior for callers that don't vary binaryParams).
37532
+ */
37533
+ groupBucketByParams(entries) {
37534
+ const keyFn = this.config.bucketParamsKey;
37535
+ if (keyFn === void 0) return [entries.slice()];
37536
+ const groups = /* @__PURE__ */ new Map();
37537
+ for (let i = 0; i < entries.length; i++) {
37538
+ const entry = entries[i];
37539
+ const key = keyFn(entry.binaryParams);
37540
+ let group = groups.get(key);
37541
+ if (group === void 0) {
37542
+ group = [];
37543
+ groups.set(key, group);
37544
+ }
37545
+ group.push(entry);
37498
37546
  }
37547
+ return Array.from(groups.values());
37499
37548
  }
37500
37549
  /** Resets the bucket flush timer. */
37501
37550
  resetBucketTimer() {
@@ -37537,25 +37586,33 @@ var SenderEngine = class {
37537
37586
  /** Attempts the binary call up to maxAttempts times. Returns undefined on success. */
37538
37587
  async attemptSend(entry, maxAttempts, startMs) {
37539
37588
  let lastError;
37540
- const lastOutput = { stdout: "", stderr: "" };
37589
+ let lastOutput = { stdout: "", stderr: "" };
37541
37590
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
37542
37591
  if (this.isStopped) {
37543
- this.activeAssociations--;
37544
- entry.resolve(err(new Error(`${this.config.senderName}: sender stopped`)));
37545
- return void 0;
37592
+ return this.resolveStopped(entry);
37546
37593
  }
37547
- const result = await this.config.executor(entry.files, entry.timeoutMs, entry.binaryParams, this.config.signal);
37548
- if (result.ok) {
37549
- this.handleSendSuccess(entry, startMs, result.value);
37594
+ const outcome = await this.config.executor(entry.files, entry.timeoutMs, entry.binaryParams, this.combinedSignal);
37595
+ lastOutput = { stdout: outcome.stdout, stderr: outcome.stderr };
37596
+ if (outcome.error === void 0) {
37597
+ this.handleSendSuccess(entry, startMs, lastOutput);
37550
37598
  return void 0;
37551
37599
  }
37552
- lastError = result.error;
37600
+ if (this.isStopped) {
37601
+ return this.resolveStopped(entry);
37602
+ }
37603
+ lastError = outcome.error;
37553
37604
  if (attempt < maxAttempts - 1) {
37554
- await delay2(this.config.retryDelayMs * (attempt + 1));
37605
+ await delayInterruptible(this.config.retryDelayMs * (attempt + 1), this.combinedSignal);
37555
37606
  }
37556
37607
  }
37557
37608
  return { error: lastError ?? new Error(`${this.config.senderName}: send failed`), output: lastOutput };
37558
37609
  }
37610
+ /** Resolves the entry as stopped and decrements activeAssociations. Used from attemptSend. */
37611
+ resolveStopped(entry) {
37612
+ this.activeAssociations--;
37613
+ entry.resolve(err(new Error(`${this.config.senderName}: sender stopped`)));
37614
+ return void 0;
37615
+ }
37559
37616
  /** Handles a successful send: updates state, emits event, resolves promise. */
37560
37617
  handleSendSuccess(entry, startMs, output) {
37561
37618
  this.activeAssociations--;
@@ -37661,11 +37718,41 @@ var SenderEngine = class {
37661
37718
  });
37662
37719
  }
37663
37720
  };
37664
- function delay2(ms) {
37721
+ function delayInterruptible(ms, signal) {
37722
+ if (signal.aborted) return Promise.resolve();
37665
37723
  return new Promise((resolve) => {
37666
- setTimeout(resolve, ms);
37724
+ const onAbort = () => {
37725
+ clearTimeout(timer);
37726
+ resolve();
37727
+ };
37728
+ const timer = setTimeout(() => {
37729
+ signal.removeEventListener("abort", onAbort);
37730
+ resolve();
37731
+ }, ms);
37732
+ signal.addEventListener("abort", onAbort, { once: true });
37667
37733
  });
37668
37734
  }
37735
+ function combineSignals(primary, external) {
37736
+ if (external === void 0) return primary;
37737
+ if (primary.aborted) return primary;
37738
+ if (external.aborted) {
37739
+ const c = new AbortController();
37740
+ c.abort(external.reason);
37741
+ return c.signal;
37742
+ }
37743
+ const merged = new AbortController();
37744
+ const onPrimary = () => {
37745
+ merged.abort(primary.reason);
37746
+ external.removeEventListener("abort", onExternal);
37747
+ };
37748
+ const onExternal = () => {
37749
+ merged.abort(external.reason);
37750
+ primary.removeEventListener("abort", onPrimary);
37751
+ };
37752
+ primary.addEventListener("abort", onPrimary, { once: true });
37753
+ external.addEventListener("abort", onExternal, { once: true });
37754
+ return merged.signal;
37755
+ }
37669
37756
 
37670
37757
  // src/senders/DicomSender.ts
37671
37758
  var DEFAULT_MAX_ASSOCIATIONS = 4;
@@ -37700,6 +37787,9 @@ var DicomSenderOptionsSchema = zod.z.object({
37700
37787
  required: zod.z.boolean().optional(),
37701
37788
  signal: zod.z.instanceof(AbortSignal).optional()
37702
37789
  }).strict();
37790
+ function storescuBucketKey(p) {
37791
+ return JSON.stringify(p);
37792
+ }
37703
37793
  function createStorescuExecutor(options) {
37704
37794
  return async (files, timeoutMs, params, signal) => {
37705
37795
  const result = await storescu({
@@ -37721,8 +37811,13 @@ function createStorescuExecutor(options) {
37721
37811
  timeoutMs,
37722
37812
  signal
37723
37813
  });
37724
- if (!result.ok) return err(result.error);
37725
- return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37814
+ if (!result.ok) {
37815
+ const e = result.error;
37816
+ const stdout = e instanceof ToolExecutionError ? e.stdout : "";
37817
+ const stderr8 = e instanceof ToolExecutionError ? e.stderr : "";
37818
+ return { stdout, stderr: stderr8, error: e };
37819
+ }
37820
+ return { stdout: result.value.stdout, stderr: result.value.stderr };
37726
37821
  };
37727
37822
  }
37728
37823
  function resolveConfig2(options) {
@@ -37779,6 +37874,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37779
37874
  executor: createStorescuExecutor(options),
37780
37875
  signal: options.signal,
37781
37876
  senderName: "DicomSender",
37877
+ bucketParamsKey: storescuBucketKey,
37782
37878
  emitters: {
37783
37879
  emitSendComplete: (data) => {
37784
37880
  senderRef.current.emit("SEND_COMPLETE", data);
@@ -37947,6 +38043,9 @@ var DicomSendOptionsSchema = zod.z.object({
37947
38043
  noUidChecks: zod.z.boolean().optional(),
37948
38044
  signal: zod.z.instanceof(AbortSignal).optional()
37949
38045
  }).strict();
38046
+ function dcmsendBucketKey(p) {
38047
+ return JSON.stringify(p);
38048
+ }
37950
38049
  function createDcmsendExecutor(options) {
37951
38050
  return async (files, timeoutMs, params, signal) => {
37952
38051
  const result = await dcmsend({
@@ -37970,8 +38069,13 @@ function createDcmsendExecutor(options) {
37970
38069
  timeoutMs,
37971
38070
  signal
37972
38071
  });
37973
- if (!result.ok) return err(result.error);
37974
- return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
38072
+ if (!result.ok) {
38073
+ const e = result.error;
38074
+ const stdout = e instanceof ToolExecutionError ? e.stdout : "";
38075
+ const stderr8 = e instanceof ToolExecutionError ? e.stderr : "";
38076
+ return { stdout, stderr: stderr8, error: e };
38077
+ }
38078
+ return { stdout: result.value.stdout, stderr: result.value.stderr };
37975
38079
  };
37976
38080
  }
37977
38081
  function resolveConfig3(options) {
@@ -38028,6 +38132,7 @@ var DicomSend = class _DicomSend extends events.EventEmitter {
38028
38132
  executor: createDcmsendExecutor(options),
38029
38133
  signal: options.signal,
38030
38134
  senderName: "DicomSend",
38135
+ bucketParamsKey: dcmsendBucketKey,
38031
38136
  emitters: {
38032
38137
  emitSendComplete: (data) => {
38033
38138
  senderRef.current.emit("SEND_COMPLETE", data);
@@ -38715,7 +38820,7 @@ function computeThroughput(succeeded, totalBytes, durationMs) {
38715
38820
  bytesPerSec: durationSec > 0 ? totalBytes / durationSec : 0
38716
38821
  };
38717
38822
  }
38718
- function delay3(ms) {
38823
+ function delay2(ms) {
38719
38824
  return new Promise((resolve) => {
38720
38825
  setTimeout(resolve, ms);
38721
38826
  });
@@ -38862,7 +38967,7 @@ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38862
38967
  /** Sends a single file, with optional delay. */
38863
38968
  async sendOneFile(file) {
38864
38969
  if (this.opts.delayMs > 0) {
38865
- await delay3(this.opts.delayMs);
38970
+ await delay2(this.opts.delayMs);
38866
38971
  }
38867
38972
  const result = await dcmsend({
38868
38973
  host: this.opts.host,
@@ -39029,6 +39134,7 @@ exports.StoreSCP = StoreSCP;
39029
39134
  exports.StoreSCPPreset = StoreSCPPreset;
39030
39135
  exports.StorescpEvent = StorescpEvent;
39031
39136
  exports.SubdirectoryMode = SubdirectoryMode;
39137
+ exports.ToolExecutionError = ToolExecutionError;
39032
39138
  exports.TransferSyntax = TransferSyntax;
39033
39139
  exports.UIDSchema = UIDSchema;
39034
39140
  exports.UNIX_SEARCH_PATHS = UNIX_SEARCH_PATHS;