@ubercode/dcmtk 0.14.0 → 0.15.1

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). */
@@ -31278,8 +31290,14 @@ var KNOWN_VR_CODES = /* @__PURE__ */ new Set([
31278
31290
  "UT",
31279
31291
  "UV"
31280
31292
  ]);
31293
+ function decodeXmlEntities(value) {
31294
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
31295
+ }
31296
+ function decodeIfString(value) {
31297
+ return typeof value === "string" ? decodeXmlEntities(value) : value;
31298
+ }
31281
31299
  function buildPnString(comp) {
31282
- const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""];
31300
+ const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""].map(decodeXmlEntities);
31283
31301
  let last = parts.length - 1;
31284
31302
  for (; last >= 0; last--) {
31285
31303
  if (parts[last] !== "") break;
@@ -31318,9 +31336,9 @@ function convertBulkDataURI(attr, element) {
31318
31336
  const bulkArray = toArray(attr.BulkDataURI);
31319
31337
  const firstBulk = bulkArray[0];
31320
31338
  if (typeof firstBulk === "object" && firstBulk !== null && "@_uri" in firstBulk) {
31321
- element.BulkDataURI = safeString(firstBulk["@_uri"]);
31339
+ element.BulkDataURI = decodeXmlEntities(safeString(firstBulk["@_uri"]));
31322
31340
  } else {
31323
- element.BulkDataURI = safeString(firstBulk);
31341
+ element.BulkDataURI = decodeXmlEntities(safeString(firstBulk));
31324
31342
  }
31325
31343
  }
31326
31344
  function convertPNValue(attr, element) {
@@ -31364,7 +31382,7 @@ function convertRegularValue(attr, element, vr) {
31364
31382
  const values = [];
31365
31383
  const isNumeric = NUMERIC_JSON_VRS.has(vr);
31366
31384
  for (const v of valArray) {
31367
- const unwrapped = unwrapValue(v);
31385
+ const unwrapped = decodeIfString(unwrapValue(v));
31368
31386
  values.push(isNumeric ? coerceNumeric(unwrapped) : unwrapped);
31369
31387
  }
31370
31388
  if (values.length > 0) element.Value = values;
@@ -33290,7 +33308,7 @@ async function dcmsend(options) {
33290
33308
  return err(result.error);
33291
33309
  }
33292
33310
  if (result.value.exitCode !== 0) {
33293
- return err(createToolError("dcmsend", args, result.value.exitCode, result.value.stderr));
33311
+ return err(createToolError("dcmsend", args, result.value.exitCode, result.value.stderr, result.value.stdout));
33294
33312
  }
33295
33313
  return ok({ success: true, stdout: result.value.stdout, stderr: result.value.stderr });
33296
33314
  }
@@ -33445,10 +33463,10 @@ async function storescu(options) {
33445
33463
  return err(result.error);
33446
33464
  }
33447
33465
  if (result.value.exitCode !== 0) {
33448
- return err(createToolError("storescu", args, result.value.exitCode, result.value.stderr));
33466
+ return err(createToolError("storescu", args, result.value.exitCode, result.value.stderr, result.value.stdout));
33449
33467
  }
33450
33468
  if (DIMSE_ERROR_PATTERN.test(result.value.stderr)) {
33451
- return err(createToolError("storescu", args, 0, result.value.stderr));
33469
+ return err(createToolError("storescu", args, 0, result.value.stderr, result.value.stdout));
33452
33470
  }
33453
33471
  return ok({ success: true, stdout: result.value.stdout, stderr: result.value.stderr });
33454
33472
  }
@@ -37341,8 +37359,14 @@ var SenderEngine = class {
37341
37359
  // Bucket state (bucket mode only)
37342
37360
  __publicField(this, "currentBucket", []);
37343
37361
  __publicField(this, "bucketTimer");
37362
+ // Abort wiring: stopController fires on stop(); combinedSignal also fires
37363
+ // when the externally provided config.signal aborts. Passed into the
37364
+ // executor and into delayInterruptible so stop() unblocks promptly.
37365
+ __publicField(this, "stopController", new AbortController());
37366
+ __publicField(this, "combinedSignal");
37344
37367
  this.config = config;
37345
37368
  this.effectiveMaxAssociations = config.configuredMaxAssociations;
37369
+ this.combinedSignal = combineSignals(this.stopController.signal, config.signal);
37346
37370
  }
37347
37371
  // -----------------------------------------------------------------------
37348
37372
  // Public API
@@ -37368,6 +37392,7 @@ var SenderEngine = class {
37368
37392
  async stop() {
37369
37393
  if (this.isStopped) return;
37370
37394
  this.isStopped = true;
37395
+ this.stopController.abort();
37371
37396
  this.clearBucketTimer();
37372
37397
  this.rejectBucket(`${this.config.senderName}: sender stopped`);
37373
37398
  this.rejectQueue(`${this.config.senderName}: sender stopped`);
@@ -37484,18 +37509,48 @@ var SenderEngine = class {
37484
37509
  }
37485
37510
  };
37486
37511
  }
37487
- /** Flushes the current bucket: combines all files, dispatches as one send. */
37512
+ /**
37513
+ * Flushes the current bucket. Entries with the same bucketParamsKey merge
37514
+ * into one underlying call; differing keys split into separate calls,
37515
+ * preserving first-occurrence order. Each group emits its own
37516
+ * BUCKET_FLUSHED. If the number of groups exceeds available capacity,
37517
+ * the surplus is queued and drains as capacity frees.
37518
+ */
37488
37519
  flushBucketInternal(reason) {
37489
37520
  if (this.currentBucket.length === 0) return;
37490
37521
  const entries = [...this.currentBucket];
37491
37522
  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);
37523
+ const groups = this.groupBucketByParams(entries);
37524
+ for (const group of groups) {
37525
+ const bucketEntry = this.mergeBucketEntries(group);
37526
+ this.config.emitters.emitBucketFlushed({ fileCount: bucketEntry.files.length, reason });
37527
+ if (this.activeAssociations < this.effectiveMaxAssociations) {
37528
+ void this.executeEntry(bucketEntry);
37529
+ } else {
37530
+ this.queue.push(bucketEntry);
37531
+ }
37532
+ }
37533
+ }
37534
+ /**
37535
+ * Groups bucket entries by bucketParamsKey while preserving first-occurrence
37536
+ * order. Without a key function, returns a single group containing every
37537
+ * entry (current behavior for callers that don't vary binaryParams).
37538
+ */
37539
+ groupBucketByParams(entries) {
37540
+ const keyFn = this.config.bucketParamsKey;
37541
+ if (keyFn === void 0) return [entries.slice()];
37542
+ const groups = /* @__PURE__ */ new Map();
37543
+ for (let i = 0; i < entries.length; i++) {
37544
+ const entry = entries[i];
37545
+ const key = keyFn(entry.binaryParams);
37546
+ let group = groups.get(key);
37547
+ if (group === void 0) {
37548
+ group = [];
37549
+ groups.set(key, group);
37550
+ }
37551
+ group.push(entry);
37498
37552
  }
37553
+ return Array.from(groups.values());
37499
37554
  }
37500
37555
  /** Resets the bucket flush timer. */
37501
37556
  resetBucketTimer() {
@@ -37537,25 +37592,33 @@ var SenderEngine = class {
37537
37592
  /** Attempts the binary call up to maxAttempts times. Returns undefined on success. */
37538
37593
  async attemptSend(entry, maxAttempts, startMs) {
37539
37594
  let lastError;
37540
- const lastOutput = { stdout: "", stderr: "" };
37595
+ let lastOutput = { stdout: "", stderr: "" };
37541
37596
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
37542
37597
  if (this.isStopped) {
37543
- this.activeAssociations--;
37544
- entry.resolve(err(new Error(`${this.config.senderName}: sender stopped`)));
37545
- return void 0;
37598
+ return this.resolveStopped(entry);
37546
37599
  }
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);
37600
+ const outcome = await this.config.executor(entry.files, entry.timeoutMs, entry.binaryParams, this.combinedSignal);
37601
+ lastOutput = { stdout: outcome.stdout, stderr: outcome.stderr };
37602
+ if (outcome.error === void 0) {
37603
+ this.handleSendSuccess(entry, startMs, lastOutput);
37550
37604
  return void 0;
37551
37605
  }
37552
- lastError = result.error;
37606
+ if (this.isStopped) {
37607
+ return this.resolveStopped(entry);
37608
+ }
37609
+ lastError = outcome.error;
37553
37610
  if (attempt < maxAttempts - 1) {
37554
- await delay2(this.config.retryDelayMs * (attempt + 1));
37611
+ await delayInterruptible(this.config.retryDelayMs * (attempt + 1), this.combinedSignal);
37555
37612
  }
37556
37613
  }
37557
37614
  return { error: lastError ?? new Error(`${this.config.senderName}: send failed`), output: lastOutput };
37558
37615
  }
37616
+ /** Resolves the entry as stopped and decrements activeAssociations. Used from attemptSend. */
37617
+ resolveStopped(entry) {
37618
+ this.activeAssociations--;
37619
+ entry.resolve(err(new Error(`${this.config.senderName}: sender stopped`)));
37620
+ return void 0;
37621
+ }
37559
37622
  /** Handles a successful send: updates state, emits event, resolves promise. */
37560
37623
  handleSendSuccess(entry, startMs, output) {
37561
37624
  this.activeAssociations--;
@@ -37661,11 +37724,41 @@ var SenderEngine = class {
37661
37724
  });
37662
37725
  }
37663
37726
  };
37664
- function delay2(ms) {
37727
+ function delayInterruptible(ms, signal) {
37728
+ if (signal.aborted) return Promise.resolve();
37665
37729
  return new Promise((resolve) => {
37666
- setTimeout(resolve, ms);
37730
+ const onAbort = () => {
37731
+ clearTimeout(timer);
37732
+ resolve();
37733
+ };
37734
+ const timer = setTimeout(() => {
37735
+ signal.removeEventListener("abort", onAbort);
37736
+ resolve();
37737
+ }, ms);
37738
+ signal.addEventListener("abort", onAbort, { once: true });
37667
37739
  });
37668
37740
  }
37741
+ function combineSignals(primary, external) {
37742
+ if (external === void 0) return primary;
37743
+ if (primary.aborted) return primary;
37744
+ if (external.aborted) {
37745
+ const c = new AbortController();
37746
+ c.abort(external.reason);
37747
+ return c.signal;
37748
+ }
37749
+ const merged = new AbortController();
37750
+ const onPrimary = () => {
37751
+ merged.abort(primary.reason);
37752
+ external.removeEventListener("abort", onExternal);
37753
+ };
37754
+ const onExternal = () => {
37755
+ merged.abort(external.reason);
37756
+ primary.removeEventListener("abort", onPrimary);
37757
+ };
37758
+ primary.addEventListener("abort", onPrimary, { once: true });
37759
+ external.addEventListener("abort", onExternal, { once: true });
37760
+ return merged.signal;
37761
+ }
37669
37762
 
37670
37763
  // src/senders/DicomSender.ts
37671
37764
  var DEFAULT_MAX_ASSOCIATIONS = 4;
@@ -37700,6 +37793,9 @@ var DicomSenderOptionsSchema = zod.z.object({
37700
37793
  required: zod.z.boolean().optional(),
37701
37794
  signal: zod.z.instanceof(AbortSignal).optional()
37702
37795
  }).strict();
37796
+ function storescuBucketKey(p) {
37797
+ return JSON.stringify(p);
37798
+ }
37703
37799
  function createStorescuExecutor(options) {
37704
37800
  return async (files, timeoutMs, params, signal) => {
37705
37801
  const result = await storescu({
@@ -37721,8 +37817,13 @@ function createStorescuExecutor(options) {
37721
37817
  timeoutMs,
37722
37818
  signal
37723
37819
  });
37724
- if (!result.ok) return err(result.error);
37725
- return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37820
+ if (!result.ok) {
37821
+ const e = result.error;
37822
+ const stdout = e instanceof ToolExecutionError ? e.stdout : "";
37823
+ const stderr8 = e instanceof ToolExecutionError ? e.stderr : "";
37824
+ return { stdout, stderr: stderr8, error: e };
37825
+ }
37826
+ return { stdout: result.value.stdout, stderr: result.value.stderr };
37726
37827
  };
37727
37828
  }
37728
37829
  function resolveConfig2(options) {
@@ -37779,6 +37880,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37779
37880
  executor: createStorescuExecutor(options),
37780
37881
  signal: options.signal,
37781
37882
  senderName: "DicomSender",
37883
+ bucketParamsKey: storescuBucketKey,
37782
37884
  emitters: {
37783
37885
  emitSendComplete: (data) => {
37784
37886
  senderRef.current.emit("SEND_COMPLETE", data);
@@ -37947,6 +38049,9 @@ var DicomSendOptionsSchema = zod.z.object({
37947
38049
  noUidChecks: zod.z.boolean().optional(),
37948
38050
  signal: zod.z.instanceof(AbortSignal).optional()
37949
38051
  }).strict();
38052
+ function dcmsendBucketKey(p) {
38053
+ return JSON.stringify(p);
38054
+ }
37950
38055
  function createDcmsendExecutor(options) {
37951
38056
  return async (files, timeoutMs, params, signal) => {
37952
38057
  const result = await dcmsend({
@@ -37970,8 +38075,13 @@ function createDcmsendExecutor(options) {
37970
38075
  timeoutMs,
37971
38076
  signal
37972
38077
  });
37973
- if (!result.ok) return err(result.error);
37974
- return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
38078
+ if (!result.ok) {
38079
+ const e = result.error;
38080
+ const stdout = e instanceof ToolExecutionError ? e.stdout : "";
38081
+ const stderr8 = e instanceof ToolExecutionError ? e.stderr : "";
38082
+ return { stdout, stderr: stderr8, error: e };
38083
+ }
38084
+ return { stdout: result.value.stdout, stderr: result.value.stderr };
37975
38085
  };
37976
38086
  }
37977
38087
  function resolveConfig3(options) {
@@ -38028,6 +38138,7 @@ var DicomSend = class _DicomSend extends events.EventEmitter {
38028
38138
  executor: createDcmsendExecutor(options),
38029
38139
  signal: options.signal,
38030
38140
  senderName: "DicomSend",
38141
+ bucketParamsKey: dcmsendBucketKey,
38031
38142
  emitters: {
38032
38143
  emitSendComplete: (data) => {
38033
38144
  senderRef.current.emit("SEND_COMPLETE", data);
@@ -38715,7 +38826,7 @@ function computeThroughput(succeeded, totalBytes, durationMs) {
38715
38826
  bytesPerSec: durationSec > 0 ? totalBytes / durationSec : 0
38716
38827
  };
38717
38828
  }
38718
- function delay3(ms) {
38829
+ function delay2(ms) {
38719
38830
  return new Promise((resolve) => {
38720
38831
  setTimeout(resolve, ms);
38721
38832
  });
@@ -38862,7 +38973,7 @@ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38862
38973
  /** Sends a single file, with optional delay. */
38863
38974
  async sendOneFile(file) {
38864
38975
  if (this.opts.delayMs > 0) {
38865
- await delay3(this.opts.delayMs);
38976
+ await delay2(this.opts.delayMs);
38866
38977
  }
38867
38978
  const result = await dcmsend({
38868
38979
  host: this.opts.host,
@@ -39029,6 +39140,7 @@ exports.StoreSCP = StoreSCP;
39029
39140
  exports.StoreSCPPreset = StoreSCPPreset;
39030
39141
  exports.StorescpEvent = StorescpEvent;
39031
39142
  exports.SubdirectoryMode = SubdirectoryMode;
39143
+ exports.ToolExecutionError = ToolExecutionError;
39032
39144
  exports.TransferSyntax = TransferSyntax;
39033
39145
  exports.UIDSchema = UIDSchema;
39034
39146
  exports.UNIX_SEARCH_PATHS = UNIX_SEARCH_PATHS;