@ubercode/dcmtk 0.8.2 → 0.9.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.
@@ -1481,6 +1481,7 @@ interface PoolStatus {
1481
1481
  /** Data emitted with FILE_RECEIVED events. */
1482
1482
  interface ReceiverFileData {
1483
1483
  readonly filePath: string;
1484
+ readonly fileSize: number;
1484
1485
  readonly associationId: string;
1485
1486
  readonly associationDir: string;
1486
1487
  readonly callingAE: string;
@@ -1481,6 +1481,7 @@ interface PoolStatus {
1481
1481
  /** Data emitted with FILE_RECEIVED events. */
1482
1482
  interface ReceiverFileData {
1483
1483
  readonly filePath: string;
1484
+ readonly fileSize: number;
1484
1485
  readonly associationId: string;
1485
1486
  readonly associationDir: string;
1486
1487
  readonly callingAE: string;
package/dist/index.cjs CHANGED
@@ -7,10 +7,11 @@ var child_process = require('child_process');
7
7
  var kill = require('tree-kill');
8
8
  var events = require('events');
9
9
  var stderrLib = require('stderr-lib');
10
- var fastXmlParser = require('fast-xml-parser');
11
10
  var fs = require('fs/promises');
12
- var net = require('net');
13
11
  var os = require('os');
12
+ var fastXmlParser = require('fast-xml-parser');
13
+ var net = require('net');
14
+ var crypto = require('crypto');
14
15
 
15
16
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
16
17
 
@@ -35,8 +36,8 @@ function _interopNamespace(e) {
35
36
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
36
37
  var kill__default = /*#__PURE__*/_interopDefault(kill);
37
38
  var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
38
- var net__namespace = /*#__PURE__*/_interopNamespace(net);
39
39
  var os__namespace = /*#__PURE__*/_interopNamespace(os);
40
+ var net__namespace = /*#__PURE__*/_interopNamespace(net);
40
41
 
41
42
  var __defProp = Object.defineProperty;
42
43
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -31501,13 +31502,26 @@ async function tryDirectPath(inputPath, timeoutMs, signal, verbosity) {
31501
31502
  if (!jsonBinary.ok) {
31502
31503
  return err(jsonBinary.error);
31503
31504
  }
31504
- const directArgs = [...buildVerbosityArgs(verbosity), "+b", inputPath];
31505
- const result = await execCommand(jsonBinary.value, directArgs, { timeoutMs, signal });
31505
+ const bulkDir = await createBulkTempDir();
31506
+ const directArgs = [...buildVerbosityArgs(verbosity), "+b", "+bd", bulkDir, inputPath];
31507
+ try {
31508
+ const execOpts = signal !== void 0 ? { timeoutMs, signal } : { timeoutMs };
31509
+ return await execAndParse(jsonBinary.value, directArgs, inputPath, execOpts);
31510
+ } finally {
31511
+ fs.rm(bulkDir, { recursive: true, force: true }).catch(() => {
31512
+ });
31513
+ }
31514
+ }
31515
+ async function createBulkTempDir() {
31516
+ return fs.mkdtemp(path.join(os.tmpdir(), "dcm2json-bulk-"));
31517
+ }
31518
+ async function execAndParse(binary, args, inputPath, execOpts) {
31519
+ const result = await execCommand(binary, args, execOpts);
31506
31520
  if (!result.ok) {
31507
31521
  return err(result.error);
31508
31522
  }
31509
31523
  if (result.value.exitCode !== 0) {
31510
- return err(createToolError("dcm2json", directArgs, result.value.exitCode, result.value.stderr));
31524
+ return err(createToolError("dcm2json", args, result.value.exitCode, result.value.stderr));
31511
31525
  }
31512
31526
  try {
31513
31527
  const repaired = repairJson(result.value.stdout);
@@ -36601,7 +36615,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36601
36615
  acseTimeout: this.options.acseTimeout,
36602
36616
  dimseTimeout: this.options.dimseTimeout,
36603
36617
  maxPdu: this.options.maxPdu,
36604
- filenameMode: this.options.filenameMode,
36618
+ filenameMode: this.options.filenameMode ?? "unique",
36605
36619
  filenameExtension: this.options.filenameExtension,
36606
36620
  storageMode: this.options.storageMode
36607
36621
  });
@@ -36716,8 +36730,9 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36716
36730
  const assocDir = worker.associationDir;
36717
36731
  const moveResult = await moveFile(srcPath, destPath);
36718
36732
  const finalPath = moveResult.ok ? destPath : srcPath;
36733
+ const fileSize = await statFileSafe(finalPath);
36719
36734
  worker.files.push(finalPath);
36720
- worker.fileSizes.push(await statFileSafe(finalPath));
36735
+ worker.fileSizes.push(fileSize);
36721
36736
  const openResult = await DicomInstance.open(finalPath);
36722
36737
  if (!openResult.ok) {
36723
36738
  this.emit("error", {
@@ -36733,6 +36748,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36733
36748
  }
36734
36749
  this.emit("FILE_RECEIVED", {
36735
36750
  filePath: finalPath,
36751
+ fileSize,
36736
36752
  associationId: assocId,
36737
36753
  associationDir: assocDir,
36738
36754
  callingAE: data.callingAE,
@@ -36881,8 +36897,8 @@ async function moveFile(src, dest) {
36881
36897
  }
36882
36898
  async function statFileSafe(filePath) {
36883
36899
  try {
36884
- const stat3 = await fs__namespace.stat(filePath);
36885
- return stat3.size;
36900
+ const stat4 = await fs__namespace.stat(filePath);
36901
+ return stat4.size;
36886
36902
  } catch {
36887
36903
  return 0;
36888
36904
  }
@@ -38293,6 +38309,341 @@ async function batch(items, operation, options) {
38293
38309
  return { results: state.results, succeeded: state.succeeded, failed: state.failed };
38294
38310
  }
38295
38311
 
38312
+ // src/testing/types.ts
38313
+ var HammerPhase = {
38314
+ /** File generation phase (copy + modify). */
38315
+ GENERATE: "generate",
38316
+ /** File sending phase (dcmsend). */
38317
+ SEND: "send"
38318
+ };
38319
+
38320
+ // src/testing/DicomHammer.ts
38321
+ var DEFAULT_FILE_COUNT = 100;
38322
+ var MAX_FILE_COUNT = 1e5;
38323
+ var DEFAULT_CONCURRENCY2 = 4;
38324
+ var MAX_CONCURRENCY2 = 64;
38325
+ var DEFAULT_CALLING_AE = "HAMMER";
38326
+ var DEFAULT_CALLED_AE = "ANY-SCP";
38327
+ var DICOM_UID_ROOT = "2.25.";
38328
+ var DicomHammerOptionsSchema = zod.z.object({
38329
+ sourceFile: zod.z.string().min(1),
38330
+ host: zod.z.string().min(1),
38331
+ port: zod.z.number().int().min(1).max(65535),
38332
+ callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
38333
+ calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
38334
+ fileCount: zod.z.number().int().min(1).max(MAX_FILE_COUNT).optional(),
38335
+ concurrency: zod.z.number().int().min(1).max(MAX_CONCURRENCY2).optional(),
38336
+ delayMs: zod.z.number().int().min(0).optional(),
38337
+ institution: zod.z.string().min(1).optional(),
38338
+ erasePrivateTags: zod.z.boolean().optional(),
38339
+ modifications: zod.z.array(zod.z.object({ tag: zod.z.string().min(1), value: zod.z.string() })).optional(),
38340
+ timeoutMs: zod.z.number().int().positive().optional(),
38341
+ signal: zod.z.instanceof(AbortSignal).optional(),
38342
+ outputDir: zod.z.string().min(1).optional(),
38343
+ noHalt: zod.z.boolean().optional()
38344
+ }).strict();
38345
+ function resolveNetworkOptions(options) {
38346
+ return {
38347
+ host: options.host,
38348
+ port: options.port,
38349
+ callingAETitle: options.callingAETitle ?? DEFAULT_CALLING_AE,
38350
+ calledAETitle: options.calledAETitle ?? DEFAULT_CALLED_AE,
38351
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
38352
+ noHalt: options.noHalt ?? true
38353
+ };
38354
+ }
38355
+ function resolveOptions(options) {
38356
+ return {
38357
+ sourceFile: options.sourceFile,
38358
+ ...resolveNetworkOptions(options),
38359
+ fileCount: options.fileCount ?? DEFAULT_FILE_COUNT,
38360
+ concurrency: options.concurrency ?? DEFAULT_CONCURRENCY2,
38361
+ delayMs: options.delayMs ?? 0,
38362
+ institution: options.institution,
38363
+ erasePrivateTags: options.erasePrivateTags ?? false,
38364
+ modifications: options.modifications ?? [],
38365
+ signal: options.signal,
38366
+ outputDir: options.outputDir ?? os.tmpdir()
38367
+ };
38368
+ }
38369
+ function generateUid() {
38370
+ const hex = crypto.randomUUID().replace(/-/g, "");
38371
+ const decimal = BigInt("0x" + hex).toString(10);
38372
+ return DICOM_UID_ROOT + decimal;
38373
+ }
38374
+ function formatDate() {
38375
+ const d = /* @__PURE__ */ new Date();
38376
+ const yyyy = String(d.getFullYear());
38377
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
38378
+ const dd = String(d.getDate()).padStart(2, "0");
38379
+ return yyyy + mm + dd;
38380
+ }
38381
+ function buildModifications(index, opts) {
38382
+ const mods = [
38383
+ { tag: "(0020,000D)", value: generateUid() },
38384
+ { tag: "(0020,000E)", value: generateUid() },
38385
+ { tag: "(0008,0018)", value: generateUid() },
38386
+ { tag: "(0008,0020)", value: formatDate() },
38387
+ { tag: "(0008,0050)", value: `HAMMER-${String(index + 1).padStart(6, "0")}` }
38388
+ ];
38389
+ if (opts.institution !== void 0) {
38390
+ mods.push({ tag: "(0008,0080)", value: opts.institution });
38391
+ }
38392
+ for (const mod of opts.modifications) {
38393
+ mods.push({ tag: mod.tag, value: mod.value });
38394
+ }
38395
+ return mods;
38396
+ }
38397
+ function computeThroughput(succeeded, totalBytes, durationMs) {
38398
+ const durationSec = durationMs / 1e3;
38399
+ return {
38400
+ filesPerSec: durationSec > 0 ? succeeded / durationSec : 0,
38401
+ bytesPerSec: durationSec > 0 ? totalBytes / durationSec : 0
38402
+ };
38403
+ }
38404
+ function delay3(ms) {
38405
+ return new Promise((resolve) => {
38406
+ setTimeout(resolve, ms);
38407
+ });
38408
+ }
38409
+ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38410
+ constructor(options) {
38411
+ super();
38412
+ __publicField(this, "opts");
38413
+ __publicField(this, "generatedDir");
38414
+ __publicField(this, "generatedFiles", []);
38415
+ this.opts = options;
38416
+ this.setMaxListeners(20);
38417
+ }
38418
+ // -----------------------------------------------------------------------
38419
+ // Factory
38420
+ // -----------------------------------------------------------------------
38421
+ /**
38422
+ * Creates a DicomHammer instance.
38423
+ *
38424
+ * @param options - Configuration for file generation and sending
38425
+ * @returns A Result containing the DicomHammer or a validation error
38426
+ */
38427
+ static create(options) {
38428
+ const validation = DicomHammerOptionsSchema.safeParse(options);
38429
+ if (!validation.success) {
38430
+ return err(createValidationError("DicomHammer", validation.error));
38431
+ }
38432
+ return ok(new _DicomHammer(resolveOptions(options)));
38433
+ }
38434
+ // -----------------------------------------------------------------------
38435
+ // Generate phase
38436
+ // -----------------------------------------------------------------------
38437
+ /**
38438
+ * Generates modified copies of the source DICOM file.
38439
+ *
38440
+ * Each copy receives unique Study/Series/SOP Instance UIDs, a unique
38441
+ * accession number, and any custom modifications.
38442
+ */
38443
+ async generate() {
38444
+ const start = Date.now();
38445
+ const dir = path.join(this.opts.outputDir, `dcmtk-hammer-${Date.now()}`);
38446
+ try {
38447
+ await fs.mkdir(dir, { recursive: true });
38448
+ } catch {
38449
+ return err(new Error(`Failed to create output directory: ${dir}`));
38450
+ }
38451
+ this.generatedDir = dir;
38452
+ const indices = Array.from({ length: this.opts.fileCount }, (_, i) => i);
38453
+ const total = this.opts.fileCount;
38454
+ let completedCount = 0;
38455
+ const result = await batch(
38456
+ indices,
38457
+ async (index) => {
38458
+ return this.generateOneFile(index, dir);
38459
+ },
38460
+ {
38461
+ concurrency: this.opts.concurrency,
38462
+ signal: this.opts.signal,
38463
+ onProgress: (_completed, _total, fileResult) => {
38464
+ completedCount++;
38465
+ if (fileResult.ok) {
38466
+ this.emit("FILE_GENERATED", { index: completedCount - 1, total, filePath: fileResult.value });
38467
+ }
38468
+ this.emitProgress(HammerPhase.GENERATE, completedCount, total);
38469
+ }
38470
+ }
38471
+ );
38472
+ const files = result.results.filter((r) => r.ok).map((r) => r.value);
38473
+ this.generatedFiles = files;
38474
+ if (files.length === 0) {
38475
+ return err(new Error(`All ${total} file generations failed`));
38476
+ }
38477
+ return ok({ files, durationMs: Date.now() - start, outputDir: dir });
38478
+ }
38479
+ /** Generates a single modified copy. Extracted for function size compliance. */
38480
+ async generateOneFile(index, dir) {
38481
+ const padded = String(index + 1).padStart(6, "0");
38482
+ const dest = path.join(dir, `hammer_${padded}.dcm`);
38483
+ const copyResult = await copyFileSafe(this.opts.sourceFile, dest);
38484
+ if (!copyResult.ok) return err(copyResult.error);
38485
+ const modResult = await dcmodify(dest, {
38486
+ modifications: buildModifications(index, this.opts),
38487
+ erasePrivateTags: this.opts.erasePrivateTags || void 0,
38488
+ insertIfMissing: true,
38489
+ noBackup: true,
38490
+ timeoutMs: this.opts.timeoutMs,
38491
+ signal: this.opts.signal
38492
+ });
38493
+ if (!modResult.ok) return err(modResult.error);
38494
+ return ok(dest);
38495
+ }
38496
+ // -----------------------------------------------------------------------
38497
+ // Send phase
38498
+ // -----------------------------------------------------------------------
38499
+ /**
38500
+ * Sends files to the target SCP.
38501
+ *
38502
+ * @param files - Files to send. Defaults to previously generated files.
38503
+ */
38504
+ async send(files) {
38505
+ const filesToSend = files ?? this.generatedFiles;
38506
+ if (filesToSend.length === 0) {
38507
+ return err(new Error("No files to send. Call generate() first or provide files."));
38508
+ }
38509
+ const fileSize = await this.getFileSize(filesToSend[0]);
38510
+ const start = Date.now();
38511
+ const total = filesToSend.length;
38512
+ const tracker = { errors: [], completedCount: 0, succeeded: 0 };
38513
+ await batch([...filesToSend], async (file) => this.sendOneFile(file), {
38514
+ concurrency: this.opts.concurrency,
38515
+ signal: this.opts.signal,
38516
+ onProgress: (_c, _t, sendResult) => this.trackSendProgress(sendResult, filesToSend, total, tracker)
38517
+ });
38518
+ return ok(this.buildSendResult(tracker, total, fileSize, Date.now() - start));
38519
+ }
38520
+ /** Tracks a single send result for progress reporting. */
38521
+ trackSendProgress(sendResult, files, total, tracker) {
38522
+ tracker.completedCount++;
38523
+ const file = files[tracker.completedCount - 1];
38524
+ if (sendResult.ok) {
38525
+ tracker.succeeded++;
38526
+ this.emit("SEND_COMPLETE", { index: tracker.completedCount - 1, total, file, durationMs: 0 });
38527
+ } else {
38528
+ tracker.errors.push({ file, error: sendResult.error });
38529
+ this.emit("SEND_FAILED", { index: tracker.completedCount - 1, total, file, error: sendResult.error });
38530
+ }
38531
+ this.emitProgress(HammerPhase.SEND, tracker.completedCount, total);
38532
+ }
38533
+ /** Assembles the final HammerSendResult. */
38534
+ buildSendResult(tracker, total, fileSize, durationMs) {
38535
+ const totalBytes = tracker.succeeded * fileSize;
38536
+ const throughput = computeThroughput(tracker.succeeded, totalBytes, durationMs);
38537
+ return {
38538
+ succeeded: tracker.succeeded,
38539
+ failed: tracker.errors.length,
38540
+ totalFiles: total,
38541
+ durationMs,
38542
+ filesPerSec: throughput.filesPerSec,
38543
+ bytesPerSec: throughput.bytesPerSec,
38544
+ totalBytes,
38545
+ errors: tracker.errors
38546
+ };
38547
+ }
38548
+ /** Sends a single file, with optional delay. */
38549
+ async sendOneFile(file) {
38550
+ if (this.opts.delayMs > 0) {
38551
+ await delay3(this.opts.delayMs);
38552
+ }
38553
+ const result = await dcmsend({
38554
+ host: this.opts.host,
38555
+ port: this.opts.port,
38556
+ files: [file],
38557
+ callingAETitle: this.opts.callingAETitle,
38558
+ calledAETitle: this.opts.calledAETitle,
38559
+ noHalt: this.opts.noHalt || void 0,
38560
+ timeoutMs: this.opts.timeoutMs,
38561
+ signal: this.opts.signal
38562
+ });
38563
+ if (!result.ok) return err(result.error);
38564
+ return ok(void 0);
38565
+ }
38566
+ /** Gets the file size in bytes, returning 0 on error. */
38567
+ async getFileSize(filePath) {
38568
+ try {
38569
+ const s = await fs.stat(filePath);
38570
+ return s.size;
38571
+ } catch {
38572
+ return 0;
38573
+ }
38574
+ }
38575
+ // -----------------------------------------------------------------------
38576
+ // Combined run
38577
+ // -----------------------------------------------------------------------
38578
+ /**
38579
+ * Generates files and sends them in one call.
38580
+ *
38581
+ * @returns Combined result with timing for both phases
38582
+ */
38583
+ async run() {
38584
+ const genResult = await this.generate();
38585
+ if (!genResult.ok) return err(genResult.error);
38586
+ const sendResult = await this.send(genResult.value.files);
38587
+ if (!sendResult.ok) return err(sendResult.error);
38588
+ const result = {
38589
+ succeeded: sendResult.value.succeeded,
38590
+ failed: sendResult.value.failed,
38591
+ totalFiles: sendResult.value.totalFiles,
38592
+ durationMs: genResult.value.durationMs + sendResult.value.durationMs,
38593
+ generateDurationMs: genResult.value.durationMs,
38594
+ sendDurationMs: sendResult.value.durationMs,
38595
+ filesPerSec: sendResult.value.filesPerSec,
38596
+ bytesPerSec: sendResult.value.bytesPerSec,
38597
+ totalBytes: sendResult.value.totalBytes,
38598
+ errors: sendResult.value.errors
38599
+ };
38600
+ this.emit("RUN_COMPLETE", result);
38601
+ return ok(result);
38602
+ }
38603
+ // -----------------------------------------------------------------------
38604
+ // Cleanup
38605
+ // -----------------------------------------------------------------------
38606
+ /** Removes all generated files and the output directory. */
38607
+ async cleanup() {
38608
+ if (this.generatedDir === void 0) return ok(void 0);
38609
+ try {
38610
+ await fs.rm(this.generatedDir, { recursive: true, force: true });
38611
+ this.generatedDir = void 0;
38612
+ this.generatedFiles = [];
38613
+ return ok(void 0);
38614
+ } catch {
38615
+ return err(new Error(`Failed to cleanup: ${this.generatedDir}`));
38616
+ }
38617
+ }
38618
+ // -----------------------------------------------------------------------
38619
+ // Event helpers
38620
+ // -----------------------------------------------------------------------
38621
+ /** Emits a PROGRESS event. */
38622
+ emitProgress(phase, completed, total) {
38623
+ this.emit("PROGRESS", { phase, completed, total, percent: Math.round(completed / total * 100) });
38624
+ }
38625
+ /** Registers a listener for FILE_GENERATED events. */
38626
+ onFileGenerated(listener) {
38627
+ return this.on("FILE_GENERATED", listener);
38628
+ }
38629
+ /** Registers a listener for SEND_COMPLETE events. */
38630
+ onSendComplete(listener) {
38631
+ return this.on("SEND_COMPLETE", listener);
38632
+ }
38633
+ /** Registers a listener for SEND_FAILED events. */
38634
+ onSendFailed(listener) {
38635
+ return this.on("SEND_FAILED", listener);
38636
+ }
38637
+ /** Registers a listener for PROGRESS events. */
38638
+ onProgress(listener) {
38639
+ return this.on("PROGRESS", listener);
38640
+ }
38641
+ /** Registers a listener for RUN_COMPLETE events. */
38642
+ onRunComplete(listener) {
38643
+ return this.on("RUN_COMPLETE", listener);
38644
+ }
38645
+ };
38646
+
38296
38647
  // src/utils/retry.ts
38297
38648
  var DEFAULT_MAX_ATTEMPTS = 3;
38298
38649
  var DEFAULT_INITIAL_DELAY_MS = 1e3;
@@ -38399,6 +38750,7 @@ exports.Dcmrecv = Dcmrecv;
38399
38750
  exports.DcmrecvEvent = DcmrecvEvent;
38400
38751
  exports.DcmtkProcess = DcmtkProcess;
38401
38752
  exports.DicomDataset = DicomDataset;
38753
+ exports.DicomHammer = DicomHammer;
38402
38754
  exports.DicomInstance = DicomInstance;
38403
38755
  exports.DicomReceiver = DicomReceiver;
38404
38756
  exports.DicomSend = DicomSend;
@@ -38407,6 +38759,7 @@ exports.DicomTagPathSchema = DicomTagPathSchema;
38407
38759
  exports.DicomTagSchema = DicomTagSchema;
38408
38760
  exports.FilenameMode = FilenameMode;
38409
38761
  exports.GetQueryModel = GetQueryModel;
38762
+ exports.HammerPhase = HammerPhase;
38410
38763
  exports.Img2dcmInputFormat = Img2dcmInputFormat;
38411
38764
  exports.JplsColorConversion = JplsColorConversion;
38412
38765
  exports.LineParser = LineParser;