@ubercode/dcmtk 0.1.4 → 0.3.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.
Files changed (40) hide show
  1. package/README.md +20 -15
  2. package/dist/DicomInstance-D9plqHp5.d.ts +625 -0
  3. package/dist/DicomInstance-DNHPkkzl.d.cts +625 -0
  4. package/dist/{dcmodify-CTXBWKU9.d.cts → dcmodify-B-_uUIKB.d.ts} +4 -2
  5. package/dist/{dcmodify-Daeafqrm.d.ts → dcmodify-Gds9u5Vj.d.cts} +4 -2
  6. package/dist/dicom.cjs +329 -51
  7. package/dist/dicom.cjs.map +1 -1
  8. package/dist/dicom.d.cts +368 -3
  9. package/dist/dicom.d.ts +368 -3
  10. package/dist/dicom.js +329 -51
  11. package/dist/dicom.js.map +1 -1
  12. package/dist/index.cjs +1993 -423
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +324 -10
  15. package/dist/index.d.ts +324 -10
  16. package/dist/index.js +1962 -417
  17. package/dist/index.js.map +1 -1
  18. package/dist/servers.cjs +2380 -197
  19. package/dist/servers.cjs.map +1 -1
  20. package/dist/servers.d.cts +1654 -3
  21. package/dist/servers.d.ts +1654 -3
  22. package/dist/servers.js +2306 -146
  23. package/dist/servers.js.map +1 -1
  24. package/dist/tools.cjs +98 -51
  25. package/dist/tools.cjs.map +1 -1
  26. package/dist/tools.d.cts +21 -4
  27. package/dist/tools.d.ts +21 -4
  28. package/dist/tools.js +98 -52
  29. package/dist/tools.js.map +1 -1
  30. package/dist/{types-zHhxS7d2.d.cts → types-Cgumy1N4.d.cts} +1 -24
  31. package/dist/{types-zHhxS7d2.d.ts → types-Cgumy1N4.d.ts} +1 -24
  32. package/dist/utils.cjs.map +1 -1
  33. package/dist/utils.d.cts +1 -1
  34. package/dist/utils.d.ts +1 -1
  35. package/dist/utils.js.map +1 -1
  36. package/package.json +8 -8
  37. package/dist/index-BZxi4104.d.ts +0 -826
  38. package/dist/index-CapkWqxy.d.ts +0 -1295
  39. package/dist/index-DX4C3zbo.d.cts +0 -826
  40. package/dist/index-r7AvpkCE.d.cts +0 -1295
package/dist/servers.cjs CHANGED
@@ -6,11 +6,37 @@ var child_process = require('child_process');
6
6
  var stderrLib = require('stderr-lib');
7
7
  var kill = require('tree-kill');
8
8
  var path = require('path');
9
- var fs = require('fs');
9
+ var fs$1 = require('fs');
10
+ var net = require('net');
11
+ var fs = require('fs/promises');
12
+ var os = require('os');
13
+ var fastXmlParser = require('fast-xml-parser');
10
14
 
11
15
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
16
 
17
+ function _interopNamespace(e) {
18
+ if (e && e.__esModule) return e;
19
+ var n = Object.create(null);
20
+ if (e) {
21
+ Object.keys(e).forEach(function (k) {
22
+ if (k !== 'default') {
23
+ var d = Object.getOwnPropertyDescriptor(e, k);
24
+ Object.defineProperty(n, k, d.get ? d : {
25
+ enumerable: true,
26
+ get: function () { return e[k]; }
27
+ });
28
+ }
29
+ });
30
+ }
31
+ n.default = e;
32
+ return Object.freeze(n);
33
+ }
34
+
13
35
  var kill__default = /*#__PURE__*/_interopDefault(kill);
36
+ var path__namespace = /*#__PURE__*/_interopNamespace(path);
37
+ var net__namespace = /*#__PURE__*/_interopNamespace(net);
38
+ var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
39
+ var os__namespace = /*#__PURE__*/_interopNamespace(os);
14
40
 
15
41
  var __defProp = Object.defineProperty;
16
42
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -25,6 +51,7 @@ function err(error) {
25
51
  }
26
52
 
27
53
  // src/constants.ts
54
+ var DEFAULT_TIMEOUT_MS = 3e4;
28
55
  var DEFAULT_START_TIMEOUT_MS = 1e4;
29
56
  var DEFAULT_DRAIN_TIMEOUT_MS = 5e3;
30
57
  var DEFAULT_BLOCK_TIMEOUT_MS = 1e3;
@@ -33,6 +60,8 @@ var UNIX_SEARCH_PATHS = ["/usr/local/bin", "/usr/bin", "/opt/local/bin", "/opt/h
33
60
  var REQUIRED_BINARIES = ["dcm2json", "dcm2xml", "dcmodify", "dcmdump", "dcmrecv", "dcmsend", "echoscu"];
34
61
  var MAX_BLOCK_LINES = 1e3;
35
62
  var MAX_EVENT_PATTERNS = 200;
63
+ var MAX_TRAVERSAL_DEPTH = 50;
64
+ var MAX_CHANGESET_OPERATIONS = 1e4;
36
65
  var MAX_OUTPUT_BYTES = 100 * 1024 * 1024;
37
66
 
38
67
  // src/DcmtkProcess.ts
@@ -380,7 +409,10 @@ var LineParser = class extends events.EventEmitter {
380
409
  };
381
410
 
382
411
  // src/patterns.ts
383
- var AE_TITLE_PATTERN = /^[A-Za-z0-9 -]+$/;
412
+ var DICOM_TAG_PATTERN = /^\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)$/;
413
+ var AE_TITLE_PATTERN = /^[\x20-\x5b\x5d-\x7e]+$/;
414
+ var UID_PATTERN = /^[0-9]+(\.[0-9]+)*$/;
415
+ var UID_MAX_LENGTH = 64;
384
416
  var PATH_TRAVERSAL_PATTERN = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
385
417
  function isValidAETitle(value) {
386
418
  return AE_TITLE_PATTERN.test(value);
@@ -397,7 +429,7 @@ function binaryName(name) {
397
429
  }
398
430
  function hasRequiredBinaries(dir) {
399
431
  for (const bin of REQUIRED_BINARIES) {
400
- if (!fs.existsSync(path.join(dir, binaryName(bin)))) {
432
+ if (!fs$1.existsSync(path.join(dir, binaryName(bin)))) {
401
433
  return false;
402
434
  }
403
435
  }
@@ -486,6 +518,39 @@ function resolveBinary(toolName) {
486
518
  return ok(path.join(pathResult.value, binaryName2));
487
519
  }
488
520
 
521
+ // src/tools/_toolError.ts
522
+ var MAX_ARGS_LENGTH = 200;
523
+ var MAX_STDERR_LENGTH = 500;
524
+ function truncate(value, maxLength) {
525
+ if (value.length <= maxLength) {
526
+ return value;
527
+ }
528
+ return `${value.substring(0, maxLength)}...`;
529
+ }
530
+ function createToolError(toolName, args, exitCode, stderr6) {
531
+ const argsStr = truncate(args.join(" "), MAX_ARGS_LENGTH);
532
+ const stderrStr = truncate(stderr6.trim(), MAX_STDERR_LENGTH);
533
+ const parts = [`${toolName} failed (exit code ${String(exitCode)})`];
534
+ if (argsStr.length > 0) {
535
+ parts.push(`args: ${argsStr}`);
536
+ }
537
+ if (stderrStr.length > 0) {
538
+ parts.push(`stderr: ${stderrStr}`);
539
+ }
540
+ return new Error(parts.join(" | "));
541
+ }
542
+ function createValidationError(toolName, zodError) {
543
+ const parts = [];
544
+ for (let i = 0; i < zodError.issues.length; i++) {
545
+ const issue = zodError.issues[i];
546
+ if (issue === void 0) continue;
547
+ const path2 = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
548
+ parts.push(`${path2}: ${issue.message}`);
549
+ }
550
+ const detail = parts.length > 0 ? parts.join("; ") : "unknown validation error";
551
+ return new Error(`${toolName}: invalid options \u2014 ${detail}`);
552
+ }
553
+
489
554
  // src/events/dcmrecv.ts
490
555
  var DcmrecvEvent = {
491
556
  LISTENING: "LISTENING",
@@ -497,7 +562,11 @@ var DcmrecvEvent = {
497
562
  ASSOCIATION_ABORTED: "ASSOCIATION_ABORTED",
498
563
  ECHO_REQUEST: "ECHO_REQUEST",
499
564
  CANNOT_START_LISTENER: "CANNOT_START_LISTENER",
500
- REFUSING_ASSOCIATION: "REFUSING_ASSOCIATION"
565
+ REFUSING_ASSOCIATION: "REFUSING_ASSOCIATION",
566
+ /** Synthetic: STORED_FILE enriched with association context. */
567
+ FILE_RECEIVED: "FILE_RECEIVED",
568
+ /** Synthetic: emitted on association release/abort with summary. */
569
+ ASSOCIATION_COMPLETE: "ASSOCIATION_COMPLETE"
501
570
  };
502
571
  var DCMRECV_PATTERNS = [
503
572
  {
@@ -507,9 +576,9 @@ var DCMRECV_PATTERNS = [
507
576
  },
508
577
  {
509
578
  event: DcmrecvEvent.ASSOCIATION_RECEIVED,
510
- pattern: /Association Received\s{1,100}([^:]+):\s*"([^"]+)"\s*->\s*"([^"]+)"/,
579
+ pattern: /Association Received\s{1,100}(\S+):\s+(\S+)\s+->\s+(\S+)/,
511
580
  processor: (match) => ({
512
- address: match[1] ?? "",
581
+ source: match[1] ?? "",
513
582
  callingAE: match[2] ?? "",
514
583
  calledAE: match[3] ?? ""
515
584
  })
@@ -537,7 +606,7 @@ var DCMRECV_PATTERNS = [
537
606
  },
538
607
  {
539
608
  event: DcmrecvEvent.ASSOCIATION_RELEASE,
540
- pattern: /Received Association Release/i,
609
+ pattern: /Association Release/i,
541
610
  processor: () => void 0
542
611
  },
543
612
  {
@@ -567,6 +636,95 @@ var DCMRECV_PATTERNS = [
567
636
  ];
568
637
  var DCMRECV_FATAL_EVENTS = /* @__PURE__ */ new Set([DcmrecvEvent.CANNOT_START_LISTENER]);
569
638
 
639
+ // src/servers/AssociationTracker.ts
640
+ var AssociationTracker = class {
641
+ constructor() {
642
+ __publicField(this, "association");
643
+ __publicField(this, "counter", 0);
644
+ }
645
+ /**
646
+ * Begins a new association, transitioning from IDLE to ACTIVE.
647
+ *
648
+ * If an association is already active, it is silently ended (abort)
649
+ * and the new one begins.
650
+ *
651
+ * @param data - Association metadata
652
+ * @returns The unique association ID
653
+ */
654
+ beginAssociation(data) {
655
+ this.counter++;
656
+ const associationId = `assoc-${String(this.counter)}`;
657
+ this.association = {
658
+ associationId,
659
+ callingAE: data.callingAE,
660
+ calledAE: data.calledAE,
661
+ source: data.source,
662
+ startTime: Date.now(),
663
+ files: []
664
+ };
665
+ return associationId;
666
+ }
667
+ /**
668
+ * Tracks a file received during the current association.
669
+ *
670
+ * If no association is active, returns a TrackedFile with empty context.
671
+ *
672
+ * @param filePath - Path to the received file
673
+ * @returns A TrackedFile enriched with association context
674
+ */
675
+ trackFile(filePath) {
676
+ if (this.association === void 0) {
677
+ return {
678
+ filePath,
679
+ associationId: "",
680
+ callingAE: "",
681
+ calledAE: "",
682
+ source: ""
683
+ };
684
+ }
685
+ this.association.files.push(filePath);
686
+ return {
687
+ filePath,
688
+ associationId: this.association.associationId,
689
+ callingAE: this.association.callingAE,
690
+ calledAE: this.association.calledAE,
691
+ source: this.association.source
692
+ };
693
+ }
694
+ /**
695
+ * Ends the current association, transitioning from ACTIVE to IDLE.
696
+ *
697
+ * @param reason - Why the association ended
698
+ * @returns An AssociationSummary, or undefined if no association was active
699
+ */
700
+ endAssociation(reason) {
701
+ if (this.association === void 0) return void 0;
702
+ const summary = {
703
+ associationId: this.association.associationId,
704
+ callingAE: this.association.callingAE,
705
+ calledAE: this.association.calledAE,
706
+ source: this.association.source,
707
+ files: [...this.association.files],
708
+ durationMs: Date.now() - this.association.startTime,
709
+ endReason: reason
710
+ };
711
+ this.association = void 0;
712
+ return summary;
713
+ }
714
+ /** The currently active association context, or undefined. */
715
+ get current() {
716
+ return this.association;
717
+ }
718
+ /** Whether an association is currently active. */
719
+ get isActive() {
720
+ return this.association !== void 0;
721
+ }
722
+ /** Resets the tracker to IDLE, discarding any active association. */
723
+ reset() {
724
+ this.association = void 0;
725
+ }
726
+ };
727
+
570
728
  // src/servers/Dcmrecv.ts
571
729
  var SubdirectoryMode = {
572
730
  NONE: "none",
@@ -651,13 +809,16 @@ function addNetworkArgs(args, options) {
651
809
  }
652
810
  }
653
811
  var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
654
- constructor(config, parser, signal) {
812
+ constructor(config, parser2, signal) {
655
813
  super(config);
656
814
  __publicField(this, "parser");
815
+ __publicField(this, "tracker");
657
816
  __publicField(this, "abortSignal");
658
817
  __publicField(this, "abortHandler");
659
- this.parser = parser;
818
+ this.parser = parser2;
819
+ this.tracker = new AssociationTracker();
660
820
  this.wireParser();
821
+ this.wireTracker();
661
822
  if (signal !== void 0) {
662
823
  this.wireAbortSignal(signal);
663
824
  }
@@ -698,6 +859,24 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
698
859
  onStoredFile(listener) {
699
860
  return this.onEvent("STORED_FILE", listener);
700
861
  }
862
+ /**
863
+ * Registers a listener for received files enriched with association context.
864
+ *
865
+ * @param listener - Callback receiving tracked file data
866
+ * @returns this for chaining
867
+ */
868
+ onFileReceived(listener) {
869
+ return this.onEvent("FILE_RECEIVED", listener);
870
+ }
871
+ /**
872
+ * Registers a listener for completed associations.
873
+ *
874
+ * @param listener - Callback receiving association summary
875
+ * @returns this for chaining
876
+ */
877
+ onAssociationComplete(listener) {
878
+ return this.onEvent("ASSOCIATION_COMPLETE", listener);
879
+ }
701
880
  /**
702
881
  * Creates a new Dcmrecv server instance.
703
882
  *
@@ -707,16 +886,16 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
707
886
  static create(options) {
708
887
  const validation = DcmrecvOptionsSchema.safeParse(options);
709
888
  if (!validation.success) {
710
- return err(new Error(`dcmrecv: invalid options: ${validation.error.message}`));
889
+ return err(createValidationError("dcmrecv", validation.error));
711
890
  }
712
891
  const binaryResult = resolveBinary("dcmrecv");
713
892
  if (!binaryResult.ok) {
714
893
  return err(binaryResult.error);
715
894
  }
716
895
  const args = buildArgs(options);
717
- const parser = new LineParser();
896
+ const parser2 = new LineParser();
718
897
  for (const pattern of DCMRECV_PATTERNS) {
719
- const addResult = parser.addPattern(pattern);
898
+ const addResult = parser2.addPattern(pattern);
720
899
  if (!addResult.ok) {
721
900
  return err(addResult.error);
722
901
  }
@@ -728,7 +907,7 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
728
907
  drainTimeoutMs: options.drainTimeoutMs,
729
908
  isStartedPredicate: (line) => /listening/i.test(line)
730
909
  };
731
- return ok(new _Dcmrecv(config, parser, options.signal));
910
+ return ok(new _Dcmrecv(config, parser2, options.signal));
732
911
  }
733
912
  /** Wires the line parser to the process output. */
734
913
  wireParser() {
@@ -740,7 +919,29 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
740
919
  this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
741
920
  void this.stop();
742
921
  }
743
- this.emit(event, ...[data]);
922
+ this.emit(event, data);
923
+ });
924
+ }
925
+ /** Wires the AssociationTracker to server events. */
926
+ wireTracker() {
927
+ this.onEvent("ASSOCIATION_RECEIVED", (data) => {
928
+ this.tracker.beginAssociation(data);
929
+ });
930
+ this.onEvent("STORED_FILE", (data) => {
931
+ const tracked = this.tracker.trackFile(data.filePath);
932
+ this.emit(DcmrecvEvent.FILE_RECEIVED, tracked);
933
+ });
934
+ this.onEvent("ASSOCIATION_RELEASE", () => {
935
+ const summary = this.tracker.endAssociation("release");
936
+ if (summary !== void 0) {
937
+ this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
938
+ }
939
+ });
940
+ this.onEvent("ASSOCIATION_ABORTED", () => {
941
+ const summary = this.tracker.endAssociation("abort");
942
+ if (summary !== void 0) {
943
+ this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
944
+ }
744
945
  });
745
946
  }
746
947
  /** Wires an AbortSignal to stop the server. */
@@ -763,6 +964,11 @@ var StorescpEvent = {
763
964
  STORING_FILE: "STORING_FILE",
764
965
  SUBDIRECTORY_CREATED: "SUBDIRECTORY_CREATED"
765
966
  };
967
+ var STORESCP_ASSOCIATION_RECEIVED = {
968
+ event: StorescpEvent.ASSOCIATION_RECEIVED,
969
+ pattern: /Association Received/i,
970
+ processor: () => ({ source: "", callingAE: "", calledAE: "" })
971
+ };
766
972
  var STORESCP_ADDITIONAL_PATTERNS = [
767
973
  {
768
974
  event: StorescpEvent.STORING_FILE,
@@ -779,7 +985,11 @@ var STORESCP_ADDITIONAL_PATTERNS = [
779
985
  })
780
986
  }
781
987
  ];
782
- var STORESCP_PATTERNS = [...DCMRECV_PATTERNS, ...STORESCP_ADDITIONAL_PATTERNS];
988
+ var STORESCP_PATTERNS = [
989
+ ...DCMRECV_PATTERNS.filter((p) => p.event !== DcmrecvEvent.ASSOCIATION_RECEIVED),
990
+ STORESCP_ASSOCIATION_RECEIVED,
991
+ ...STORESCP_ADDITIONAL_PATTERNS
992
+ ];
783
993
  var STORESCP_FATAL_EVENTS = /* @__PURE__ */ new Set([...DCMRECV_FATAL_EVENTS]);
784
994
 
785
995
  // src/servers/StoreSCP.ts
@@ -789,6 +999,23 @@ var PreferredTransferSyntax = {
789
999
  IMPLICIT: "implicit",
790
1000
  ACCEPT_ALL: "accept-all"
791
1001
  };
1002
+ var StoreSCPPreset = {
1003
+ /** Basic storage: unique filenames to avoid collisions. */
1004
+ BASIC_STORAGE: {
1005
+ uniqueFilenames: true
1006
+ },
1007
+ /** Testing: unique filenames, preserving raw transfer syntax. */
1008
+ TESTING: {
1009
+ uniqueFilenames: true,
1010
+ bitPreserving: true
1011
+ },
1012
+ /** Production: unique filenames with reasonable timeouts. */
1013
+ PRODUCTION: {
1014
+ uniqueFilenames: true,
1015
+ acseTimeout: 30,
1016
+ dimseTimeout: 60
1017
+ }
1018
+ };
792
1019
  var StoreSCPOptionsSchema = zod.z.object({
793
1020
  port: zod.z.number().int().min(1).max(65535),
794
1021
  aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
@@ -895,24 +1122,20 @@ function buildExecArgs(args, options) {
895
1122
  }
896
1123
  }
897
1124
  var StoreSCP = class _StoreSCP extends DcmtkProcess {
898
- constructor(config, parser, signal) {
1125
+ constructor(config, parser2, signal) {
899
1126
  super(config);
900
1127
  __publicField(this, "parser");
1128
+ __publicField(this, "tracker");
901
1129
  __publicField(this, "abortSignal");
902
1130
  __publicField(this, "abortHandler");
903
- this.parser = parser;
1131
+ this.parser = parser2;
1132
+ this.tracker = new AssociationTracker();
904
1133
  this.wireParser();
1134
+ this.wireTracker();
905
1135
  if (signal !== void 0) {
906
1136
  this.wireAbortSignal(signal);
907
1137
  }
908
1138
  }
909
- /**
910
- * Registers a typed listener for a storescp-specific event.
911
- *
912
- * @param event - The event name from StoreSCPEventMap
913
- * @param listener - Callback receiving typed event data
914
- * @returns this for chaining
915
- */
916
1139
  /** Disposes the server and its parser, preventing listener leaks. */
917
1140
  [Symbol.dispose]() {
918
1141
  if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
@@ -921,6 +1144,13 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
921
1144
  this.parser[Symbol.dispose]();
922
1145
  super[Symbol.dispose]();
923
1146
  }
1147
+ /**
1148
+ * Registers a typed listener for a storescp-specific event.
1149
+ *
1150
+ * @param event - The event name from StoreSCPEventMap
1151
+ * @param listener - Callback receiving typed event data
1152
+ * @returns this for chaining
1153
+ */
924
1154
  onEvent(event, listener) {
925
1155
  return this.on(event, listener);
926
1156
  }
@@ -942,6 +1172,24 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
942
1172
  onStoringFile(listener) {
943
1173
  return this.onEvent("STORING_FILE", listener);
944
1174
  }
1175
+ /**
1176
+ * Registers a listener for received files enriched with association context.
1177
+ *
1178
+ * @param listener - Callback receiving tracked file data
1179
+ * @returns this for chaining
1180
+ */
1181
+ onFileReceived(listener) {
1182
+ return this.onEvent("FILE_RECEIVED", listener);
1183
+ }
1184
+ /**
1185
+ * Registers a listener for completed associations.
1186
+ *
1187
+ * @param listener - Callback receiving association summary
1188
+ * @returns this for chaining
1189
+ */
1190
+ onAssociationComplete(listener) {
1191
+ return this.onEvent("ASSOCIATION_COMPLETE", listener);
1192
+ }
945
1193
  /**
946
1194
  * Creates a new StoreSCP server instance.
947
1195
  *
@@ -951,16 +1199,16 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
951
1199
  static create(options) {
952
1200
  const validation = StoreSCPOptionsSchema.safeParse(options);
953
1201
  if (!validation.success) {
954
- return err(new Error(`storescp: invalid options: ${validation.error.message}`));
1202
+ return err(createValidationError("storescp", validation.error));
955
1203
  }
956
1204
  const binaryResult = resolveBinary("storescp");
957
1205
  if (!binaryResult.ok) {
958
1206
  return err(binaryResult.error);
959
1207
  }
960
1208
  const args = buildArgs2(options);
961
- const parser = new LineParser();
1209
+ const parser2 = new LineParser();
962
1210
  for (const pattern of STORESCP_PATTERNS) {
963
- const addResult = parser.addPattern(pattern);
1211
+ const addResult = parser2.addPattern(pattern);
964
1212
  if (!addResult.ok) {
965
1213
  return err(addResult.error);
966
1214
  }
@@ -972,7 +1220,7 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
972
1220
  drainTimeoutMs: options.drainTimeoutMs
973
1221
  // storescp doesn't print "listening" — resolve on spawn
974
1222
  };
975
- return ok(new _StoreSCP(config, parser, options.signal));
1223
+ return ok(new _StoreSCP(config, parser2, options.signal));
976
1224
  }
977
1225
  /** Wires the line parser to the process output. */
978
1226
  wireParser() {
@@ -984,7 +1232,33 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
984
1232
  this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
985
1233
  void this.stop();
986
1234
  }
987
- this.emit(event, ...[data]);
1235
+ this.emit(event, data);
1236
+ });
1237
+ }
1238
+ /** Wires the AssociationTracker to server events. */
1239
+ wireTracker() {
1240
+ this.onEvent("ASSOCIATION_RECEIVED", (data) => {
1241
+ this.tracker.beginAssociation(data);
1242
+ });
1243
+ this.onEvent("STORING_FILE", (data) => {
1244
+ const tracked = this.tracker.trackFile(data.filePath);
1245
+ this.emit(DcmrecvEvent.FILE_RECEIVED, tracked);
1246
+ });
1247
+ this.onEvent("STORED_FILE", (data) => {
1248
+ const tracked = this.tracker.trackFile(data.filePath);
1249
+ this.emit(DcmrecvEvent.FILE_RECEIVED, tracked);
1250
+ });
1251
+ this.onEvent("ASSOCIATION_RELEASE", () => {
1252
+ const summary = this.tracker.endAssociation("release");
1253
+ if (summary !== void 0) {
1254
+ this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
1255
+ }
1256
+ });
1257
+ this.onEvent("ASSOCIATION_ABORTED", () => {
1258
+ const summary = this.tracker.endAssociation("abort");
1259
+ if (summary !== void 0) {
1260
+ this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
1261
+ }
988
1262
  });
989
1263
  }
990
1264
  /** Wires an AbortSignal to stop the server. */
@@ -1000,160 +1274,2066 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
1000
1274
  signal.addEventListener("abort", this.abortHandler, { once: true });
1001
1275
  }
1002
1276
  };
1003
-
1004
- // src/events/dcmprscp.ts
1005
- var DcmprscpEvent = {
1006
- DATABASE_READY: "DATABASE_READY",
1007
- ASSOCIATION_RECEIVED: "ASSOCIATION_RECEIVED",
1008
- ASSOCIATION_ACKNOWLEDGED: "ASSOCIATION_ACKNOWLEDGED",
1009
- ASSOCIATION_RELEASE: "ASSOCIATION_RELEASE",
1010
- ASSOCIATION_ABORTED: "ASSOCIATION_ABORTED",
1011
- CANNOT_START_LISTENER: "CANNOT_START_LISTENER",
1012
- CONFIG_ERROR: "CONFIG_ERROR"
1013
- };
1014
- var DCMPRSCP_PATTERNS = [
1015
- {
1016
- event: DcmprscpEvent.DATABASE_READY,
1017
- pattern: /Using database in directory\s+'([^']+)'/,
1018
- processor: (match) => ({
1019
- directory: match[1] ?? ""
1020
- })
1021
- },
1022
- {
1023
- event: DcmprscpEvent.ASSOCIATION_RECEIVED,
1024
- pattern: /Association Received\s{0,20}\(([^)]+)\)/,
1025
- processor: (match) => ({
1026
- peerInfo: (match[1] ?? "").trim()
1027
- })
1028
- },
1029
- {
1030
- event: DcmprscpEvent.ASSOCIATION_ACKNOWLEDGED,
1031
- pattern: /Association Acknowledged \(Max Send PDV:\s*(\d+)\)/,
1032
- processor: (match) => ({
1033
- maxSendPDV: Number(match[1])
1034
- })
1035
- },
1036
- {
1037
- event: DcmprscpEvent.ASSOCIATION_RELEASE,
1038
- pattern: /Association Release/i,
1039
- processor: () => void 0
1040
- },
1041
- {
1042
- event: DcmprscpEvent.ASSOCIATION_ABORTED,
1043
- pattern: /Association Abort/i,
1044
- processor: () => void 0
1045
- },
1046
- {
1047
- event: DcmprscpEvent.CANNOT_START_LISTENER,
1048
- pattern: /cannot initialise network|cannot listen/i,
1049
- processor: (match) => ({
1050
- message: match[0] ?? ""
1051
- })
1052
- },
1053
- {
1054
- event: DcmprscpEvent.CONFIG_ERROR,
1055
- pattern: /can't open configuration file|no (?:default )?print scp/i,
1056
- processor: (match) => ({
1057
- message: match[0] ?? ""
1058
- })
1277
+ function createDicomTag(input) {
1278
+ if (!DICOM_TAG_PATTERN.test(input)) {
1279
+ return err(new Error(`Invalid DICOM tag: "${input}". Expected format (XXXX,XXXX) where X is a hex digit`));
1059
1280
  }
1060
- ];
1061
- var DCMPRSCP_FATAL_EVENTS = /* @__PURE__ */ new Set([DcmprscpEvent.CANNOT_START_LISTENER, DcmprscpEvent.CONFIG_ERROR]);
1281
+ return ok(input);
1282
+ }
1283
+ function createSOPClassUID(input) {
1284
+ if (input.length === 0 || input.length > UID_MAX_LENGTH) {
1285
+ return err(new Error(`Invalid SOP Class UID: "${input}". Must be 1-${UID_MAX_LENGTH} characters`));
1286
+ }
1287
+ if (!UID_PATTERN.test(input)) {
1288
+ return err(new Error(`Invalid SOP Class UID: "${input}". Must be a dotted numeric OID`));
1289
+ }
1290
+ return ok(input);
1291
+ }
1292
+ function createDicomFilePath(input) {
1293
+ if (input.length === 0) {
1294
+ return err(new Error("Invalid DICOM file path: empty string"));
1295
+ }
1296
+ if (PATH_TRAVERSAL_PATTERN.test(input)) {
1297
+ return err(new Error(`Invalid DICOM file path: path traversal detected in "${input}"`));
1298
+ }
1299
+ const normalized = path.normalize(input);
1300
+ return ok(normalized);
1301
+ }
1062
1302
 
1063
- // src/servers/DcmprsCP.ts
1064
- var DcmprsCPOptionsSchema = zod.z.object({
1065
- configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }),
1066
- printer: zod.z.string().min(1).optional(),
1067
- dump: zod.z.boolean().optional(),
1068
- logLevel: zod.z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).optional(),
1069
- logConfig: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in logConfig" }).optional(),
1070
- startTimeoutMs: zod.z.number().int().positive().optional(),
1071
- drainTimeoutMs: zod.z.number().int().positive().optional(),
1072
- signal: zod.z.instanceof(AbortSignal).optional()
1073
- }).strict();
1074
- function buildArgs3(options) {
1075
- const args = ["--verbose", "--config", options.configFile];
1076
- if (options.printer !== void 0) {
1077
- args.push("--printer", options.printer);
1303
+ // src/dicom/tagPath.ts
1304
+ var SEGMENT_PATTERN = /^\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)(?:\[(\d+|\*)\])?/;
1305
+ function matchToSegment(match) {
1306
+ const group = match[1];
1307
+ const element = match[2];
1308
+ const indexStr = match[3];
1309
+ if (group === void 0 || element === void 0) {
1310
+ throw new Error("Failed to parse tag group/element");
1311
+ }
1312
+ const tagResult = createDicomTag(`(${group},${element})`);
1313
+ if (!tagResult.ok) throw new Error(`Invalid tag in path: (${group},${element})`);
1314
+ if (indexStr === "*") return { tag: tagResult.value, isWildcard: true };
1315
+ if (indexStr !== void 0) return { tag: tagResult.value, index: Number(indexStr) };
1316
+ return { tag: tagResult.value };
1317
+ }
1318
+ function advancePastMatch(remaining, matchLength) {
1319
+ const after = remaining.slice(matchLength);
1320
+ if (!after.startsWith(".")) return after;
1321
+ if (after.length === 1) throw new Error("Tag path cannot end with a dot separator");
1322
+ return after.slice(1);
1323
+ }
1324
+ function tagPathToSegments(path2) {
1325
+ const segments = [];
1326
+ let remaining = path2;
1327
+ for (let i = 0; i < MAX_TRAVERSAL_DEPTH && remaining.length > 0; i++) {
1328
+ const match = SEGMENT_PATTERN.exec(remaining);
1329
+ if (match === null) {
1330
+ throw new Error(`Invalid tag path segment at position ${path2.length - remaining.length}: "${remaining}"`);
1331
+ }
1332
+ segments.push(matchToSegment(match));
1333
+ remaining = advancePastMatch(remaining, match[0].length);
1078
1334
  }
1079
- if (options.dump === true) {
1080
- args.push("--dump");
1335
+ if (remaining.length > 0) throw new Error(`Tag path exceeds maximum depth of ${MAX_TRAVERSAL_DEPTH}`);
1336
+ if (segments.length === 0) throw new Error("Tag path is empty");
1337
+ return segments;
1338
+ }
1339
+
1340
+ // src/dicom/ChangeSet.ts
1341
+ var ERASE_PRIVATE_SENTINEL = "__ERASE_PRIVATE__";
1342
+ function isControlChar(code) {
1343
+ if (code <= 9) return true;
1344
+ if (code === 11 || code === 12) return true;
1345
+ if (code >= 14 && code <= 31) return true;
1346
+ return code === 127;
1347
+ }
1348
+ function sanitizeValue(value) {
1349
+ let result = "";
1350
+ for (let i = 0; i < value.length; i++) {
1351
+ const code = value.charCodeAt(i);
1352
+ if (!isControlChar(code)) {
1353
+ result += value[i];
1354
+ }
1081
1355
  }
1082
- if (options.logLevel !== void 0) {
1083
- args.push("--log-level", options.logLevel);
1356
+ return result;
1357
+ }
1358
+ function buildMergedModifications(base, other, erasures) {
1359
+ const merged = new Map(base);
1360
+ for (const [key, value] of other) {
1361
+ merged.set(key, value);
1084
1362
  }
1085
- if (options.logConfig !== void 0) {
1086
- args.push("--log-config", options.logConfig);
1363
+ for (const key of erasures) {
1364
+ merged.delete(key);
1087
1365
  }
1088
- return args;
1366
+ return merged;
1089
1367
  }
1090
- var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
1091
- constructor(config, parser, signal) {
1092
- super(config);
1093
- __publicField(this, "parser");
1094
- __publicField(this, "abortSignal");
1095
- __publicField(this, "abortHandler");
1096
- this.parser = parser;
1097
- this.wireParser();
1098
- if (signal !== void 0) {
1099
- this.wireAbortSignal(signal);
1368
+ function applyBatchEntries(initial, entries) {
1369
+ const keys = Object.keys(entries);
1370
+ let cs = initial;
1371
+ for (let i = 0; i < keys.length; i++) {
1372
+ const key = keys[i];
1373
+ if (key === void 0) continue;
1374
+ const value = entries[key];
1375
+ if (value === void 0) continue;
1376
+ cs = cs.setTag(key, value);
1377
+ }
1378
+ return cs;
1379
+ }
1380
+ var ChangeSet = class _ChangeSet {
1381
+ constructor(mods, erasures) {
1382
+ __publicField(this, "mods");
1383
+ __publicField(this, "erased");
1384
+ this.mods = mods;
1385
+ this.erased = erasures;
1386
+ }
1387
+ /** Creates an empty ChangeSet with no modifications or erasures. */
1388
+ static empty() {
1389
+ return new _ChangeSet(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
1390
+ }
1391
+ /**
1392
+ * Sets a tag value, returning a new ChangeSet.
1393
+ *
1394
+ * Control characters (except LF/CR) are stripped from the value.
1395
+ * If the tag was previously erased, it is removed from the erasure set.
1396
+ *
1397
+ * @param path - The DICOM tag path to set (e.g. `'(0010,0010)'`)
1398
+ * @param value - The new value for the tag
1399
+ * @returns A new ChangeSet with the modification applied
1400
+ * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1401
+ */
1402
+ setTag(path2, value) {
1403
+ const totalOps = this.mods.size + this.erased.size;
1404
+ if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1405
+ throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1100
1406
  }
1407
+ tagPathToSegments(path2);
1408
+ const sanitized = sanitizeValue(value);
1409
+ const newMods = new Map(this.mods);
1410
+ newMods.set(path2, sanitized);
1411
+ const newErasures = new Set(this.erased);
1412
+ newErasures.delete(path2);
1413
+ return new _ChangeSet(newMods, newErasures);
1101
1414
  }
1102
1415
  /**
1103
- * Registers a typed listener for a dcmprscp-specific event.
1416
+ * Marks a tag for erasure, returning a new ChangeSet.
1104
1417
  *
1105
- * @param event - The event name from DcmprsCPEventMap
1106
- * @param listener - Callback receiving typed event data
1107
- * @returns this for chaining
1418
+ * If the tag was previously set, the modification is removed.
1419
+ *
1420
+ * @param path - The DICOM tag path to erase (e.g. `'(0010,0010)'`)
1421
+ * @returns A new ChangeSet with the erasure applied
1422
+ * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1108
1423
  */
1109
- /** Disposes the server and its parser, preventing listener leaks. */
1110
- [Symbol.dispose]() {
1111
- if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
1112
- this.abortSignal.removeEventListener("abort", this.abortHandler);
1424
+ eraseTag(path2) {
1425
+ const totalOps = this.mods.size + this.erased.size;
1426
+ if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1427
+ throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1113
1428
  }
1114
- this.parser[Symbol.dispose]();
1115
- super[Symbol.dispose]();
1429
+ tagPathToSegments(path2);
1430
+ const newMods = new Map(this.mods);
1431
+ newMods.delete(path2);
1432
+ const newErasures = new Set(this.erased);
1433
+ newErasures.add(path2);
1434
+ return new _ChangeSet(newMods, newErasures);
1116
1435
  }
1117
- onEvent(event, listener) {
1118
- return this.on(event, listener);
1436
+ /**
1437
+ * Marks all private tags for erasure, returning a new ChangeSet.
1438
+ *
1439
+ * @returns A new ChangeSet with the erase-private flag set
1440
+ * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1441
+ */
1442
+ erasePrivateTags() {
1443
+ const totalOps = this.mods.size + this.erased.size;
1444
+ if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1445
+ throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1446
+ }
1447
+ const newErasures = new Set(this.erased);
1448
+ newErasures.add(ERASE_PRIVATE_SENTINEL);
1449
+ return new _ChangeSet(new Map(this.mods), newErasures);
1450
+ }
1451
+ // -----------------------------------------------------------------------
1452
+ // Convenience setters for common DICOM tags
1453
+ // -----------------------------------------------------------------------
1454
+ /** Sets Patient's Name (0010,0010). */
1455
+ setPatientName(value) {
1456
+ return this.setTag("(0010,0010)", value);
1457
+ }
1458
+ /** Sets Patient ID (0010,0020). */
1459
+ setPatientID(value) {
1460
+ return this.setTag("(0010,0020)", value);
1461
+ }
1462
+ /** Sets Study Date (0008,0020). */
1463
+ setStudyDate(value) {
1464
+ return this.setTag("(0008,0020)", value);
1465
+ }
1466
+ /** Sets Modality (0008,0060). */
1467
+ setModality(value) {
1468
+ return this.setTag("(0008,0060)", value);
1469
+ }
1470
+ /** Sets Accession Number (0008,0050). */
1471
+ setAccessionNumber(value) {
1472
+ return this.setTag("(0008,0050)", value);
1473
+ }
1474
+ /** Sets Study Description (0008,1030). */
1475
+ setStudyDescription(value) {
1476
+ return this.setTag("(0008,1030)", value);
1477
+ }
1478
+ /** Sets Series Description (0008,103E). */
1479
+ setSeriesDescription(value) {
1480
+ return this.setTag("(0008,103E)", value);
1481
+ }
1482
+ /** Sets Institution Name (0008,0080). */
1483
+ setInstitutionName(value) {
1484
+ return this.setTag("(0008,0080)", value);
1119
1485
  }
1120
1486
  /**
1121
- * Registers a listener for when the database is ready.
1487
+ * Sets multiple tags at once, returning a new ChangeSet.
1122
1488
  *
1123
- * @param listener - Callback receiving database ready data
1124
- * @returns this for chaining
1489
+ * @param entries - A record of tag path → value pairs
1490
+ * @returns A new ChangeSet with all modifications applied
1125
1491
  */
1126
- onDatabaseReady(listener) {
1127
- return this.onEvent("DATABASE_READY", listener);
1492
+ setBatch(entries) {
1493
+ return applyBatchEntries(this, entries);
1494
+ }
1495
+ /** All pending tag modifications as a readonly map of path → value. */
1496
+ get modifications() {
1497
+ return this.mods;
1498
+ }
1499
+ /** All pending tag erasures as a readonly set of paths. */
1500
+ get erasures() {
1501
+ return this.erased;
1502
+ }
1503
+ /** Total number of operations (modifications + erasures) in this ChangeSet. */
1504
+ get operationCount() {
1505
+ return this.mods.size + this.erased.size;
1506
+ }
1507
+ /** Whether the ChangeSet has no modifications and no erasures. */
1508
+ get isEmpty() {
1509
+ return this.mods.size === 0 && this.erased.size === 0;
1510
+ }
1511
+ /** Whether the erase-all-private-tags flag is set. */
1512
+ get erasePrivate() {
1513
+ return this.erased.has(ERASE_PRIVATE_SENTINEL);
1128
1514
  }
1129
1515
  /**
1130
- * Registers a listener for incoming associations.
1516
+ * Merges another ChangeSet into this one, returning a new ChangeSet.
1131
1517
  *
1132
- * @param listener - Callback receiving association data
1133
- * @returns this for chaining
1518
+ * The `other` ChangeSet wins on conflicts: if the same tag is modified in both,
1519
+ * `other`'s value is used. Erasures from both sets are unioned. An erasure in
1520
+ * `other` removes a modification from `base`.
1521
+ *
1522
+ * @param other - The ChangeSet to merge in
1523
+ * @returns A new ChangeSet with merged modifications and erasures
1134
1524
  */
1135
- onAssociationReceived(listener) {
1136
- return this.onEvent("ASSOCIATION_RECEIVED", listener);
1525
+ merge(other) {
1526
+ const mergedErasures = /* @__PURE__ */ new Set([...this.erased, ...other.erased]);
1527
+ const mergedMods = buildMergedModifications(this.mods, other.mods, mergedErasures);
1528
+ return new _ChangeSet(mergedMods, mergedErasures);
1137
1529
  }
1138
1530
  /**
1139
- * Creates a new DcmprsCP server instance.
1531
+ * Converts modifications to dcmodify-compatible TagModification array.
1140
1532
  *
1141
- * @param options - Configuration options for the dcmprscp server
1142
- * @returns A Result containing the server instance or a validation/resolution error
1533
+ * @returns A readonly array of TagModification objects
1143
1534
  */
1144
- static create(options) {
1145
- const validation = DcmprsCPOptionsSchema.safeParse(options);
1146
- if (!validation.success) {
1147
- return err(new Error(`dcmprscp: invalid options: ${validation.error.message}`));
1535
+ toModifications() {
1536
+ const result = [];
1537
+ for (const [tag, value] of this.mods) {
1538
+ result.push({ tag, value });
1148
1539
  }
1149
- const binaryResult = resolveBinary("dcmprscp");
1150
- if (!binaryResult.ok) {
1151
- return err(binaryResult.error);
1540
+ return result;
1541
+ }
1542
+ /**
1543
+ * Converts erasures to dcmodify-compatible argument strings.
1544
+ *
1545
+ * The erase-private sentinel is excluded — use {@link erasePrivate} to check
1546
+ * whether `-ep` should be passed.
1547
+ *
1548
+ * @returns A readonly array of tag path strings for `-e` arguments
1549
+ */
1550
+ toErasureArgs() {
1551
+ const result = [];
1552
+ for (const path2 of this.erased) {
1553
+ if (path2 !== ERASE_PRIVATE_SENTINEL) {
1554
+ result.push(path2);
1555
+ }
1556
+ }
1557
+ return result;
1558
+ }
1559
+ };
1560
+ var HEX_TAG_KEY = /^[0-9A-Fa-f]{8}$/;
1561
+ function isDicomJsonElement(value) {
1562
+ return typeof value === "object" && value !== null && "vr" in value && typeof value["vr"] === "string";
1563
+ }
1564
+ function isValidDicomJsonModel(obj) {
1565
+ const keys = Object.keys(obj);
1566
+ for (let i = 0; i < keys.length; i++) {
1567
+ const key = keys[i];
1568
+ if (key === void 0) continue;
1569
+ if (!HEX_TAG_KEY.test(key)) return false;
1570
+ if (!isDicomJsonElement(obj[key])) return false;
1571
+ }
1572
+ return true;
1573
+ }
1574
+ var TAG_WITH_PARENS = /^\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)$/;
1575
+ var TAG_HEX_ONLY = /^[0-9A-Fa-f]{8}$/;
1576
+ function normalizeTag(tag) {
1577
+ const parenMatch = TAG_WITH_PARENS.exec(tag);
1578
+ if (parenMatch !== null) {
1579
+ const group = parenMatch[1];
1580
+ const element = parenMatch[2];
1581
+ if (group === void 0 || element === void 0) return err(new Error(`Invalid tag: "${tag}"`));
1582
+ return ok(`${group}${element}`.toUpperCase());
1583
+ }
1584
+ if (TAG_HEX_ONLY.test(tag)) {
1585
+ return ok(tag.toUpperCase());
1586
+ }
1587
+ return err(new Error(`Invalid tag format: "${tag}". Expected (XXXX,XXXX) or XXXXXXXX`));
1588
+ }
1589
+ function resolveElement(data, tagKey) {
1590
+ return data[tagKey];
1591
+ }
1592
+ function extractPNAlphabetic(first) {
1593
+ if (typeof first !== "object" || first === null) return "";
1594
+ const pn = first;
1595
+ const alphabetic = pn["Alphabetic"];
1596
+ return typeof alphabetic === "string" ? alphabetic : "";
1597
+ }
1598
+ function primitiveToString(value) {
1599
+ if (typeof value === "string") return value;
1600
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1601
+ return "";
1602
+ }
1603
+ function extractString(element) {
1604
+ const values = element.Value;
1605
+ if (values === void 0 || values.length === 0) return "";
1606
+ const first = values[0];
1607
+ if (first === void 0 || first === null) return "";
1608
+ if (element.vr === "PN") return extractPNAlphabetic(first);
1609
+ return primitiveToString(first);
1610
+ }
1611
+ function extractNumber(element) {
1612
+ const values = element.Value;
1613
+ if (values === void 0 || values.length === 0) {
1614
+ return err(new Error("Element has no Value array"));
1615
+ }
1616
+ const first = values[0];
1617
+ if (typeof first === "number") return ok(first);
1618
+ if (typeof first === "string") {
1619
+ const parsed = Number(first);
1620
+ if (Number.isNaN(parsed)) {
1621
+ return err(new Error(`Cannot convert "${first}" to number`));
1622
+ }
1623
+ return ok(parsed);
1624
+ }
1625
+ return err(new Error(`Value is not numeric: ${typeof first}`));
1626
+ }
1627
+ function extractStrings(element) {
1628
+ const values = element.Value;
1629
+ if (values === void 0) return [];
1630
+ const result = [];
1631
+ for (const v of values) {
1632
+ if (typeof v === "string") {
1633
+ result.push(v);
1634
+ } else if (typeof v === "number" || typeof v === "boolean") {
1635
+ result.push(String(v));
1636
+ } else {
1637
+ result.push("");
1638
+ }
1639
+ }
1640
+ return result;
1641
+ }
1642
+ function getSequenceItems(element) {
1643
+ if (element.vr !== "SQ" || element.Value === void 0) return void 0;
1644
+ return element.Value;
1645
+ }
1646
+ function isPlausibleModel(value) {
1647
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1648
+ }
1649
+ function descendIntoSequence(element, seg) {
1650
+ const items = getSequenceItems(element);
1651
+ if (items === void 0) {
1652
+ return err(new Error(`Tag ${seg.tag} is not a sequence`));
1653
+ }
1654
+ const idx = seg.index ?? 0;
1655
+ const item = items[idx];
1656
+ if (!isPlausibleModel(item)) {
1657
+ return err(new Error(`Sequence ${seg.tag} has no valid item at index ${idx}`));
1658
+ }
1659
+ return ok(item);
1660
+ }
1661
+ function traversePath(data, segments) {
1662
+ let current = data;
1663
+ for (let i = 0; i < segments.length; i++) {
1664
+ const seg = segments[i];
1665
+ if (seg === void 0) return err(new Error("Unexpected undefined segment"));
1666
+ const tagResult = normalizeTag(seg.tag);
1667
+ if (!tagResult.ok) return err(tagResult.error);
1668
+ const element = resolveElement(current, tagResult.value);
1669
+ if (element === void 0) return err(new Error(`Tag ${seg.tag} not found`));
1670
+ if (i === segments.length - 1) return ok(element);
1671
+ const descent = descendIntoSequence(element, seg);
1672
+ if (!descent.ok) return err(descent.error);
1673
+ current = descent.value;
1674
+ }
1675
+ return err(new Error("Empty path segments"));
1676
+ }
1677
+ function collectWildcard(data, segments) {
1678
+ const results = [];
1679
+ const queue = [{ data, segmentIndex: 0 }];
1680
+ const maxIterations = MAX_TRAVERSAL_DEPTH * 100;
1681
+ let iteration = 0;
1682
+ for (; iteration < maxIterations && queue.length > 0; iteration++) {
1683
+ const entry = queue.shift();
1684
+ if (entry === void 0) break;
1685
+ processQueueEntry(entry, segments, results, queue);
1686
+ }
1687
+ return { values: results, truncated: iteration >= maxIterations && queue.length > 0 };
1688
+ }
1689
+ function processQueueEntry(entry, segments, results, queue) {
1690
+ const seg = segments[entry.segmentIndex];
1691
+ if (seg === void 0) return;
1692
+ const tagResult = normalizeTag(seg.tag);
1693
+ if (!tagResult.ok) return;
1694
+ const element = resolveElement(entry.data, tagResult.value);
1695
+ if (element === void 0) return;
1696
+ const isLast = entry.segmentIndex === segments.length - 1;
1697
+ if (isLast) {
1698
+ collectLeafValues(element, results);
1699
+ return;
1700
+ }
1701
+ enqueueSequenceItems(element, seg, entry.segmentIndex, queue);
1702
+ }
1703
+ function collectLeafValues(element, results) {
1704
+ if (element.Value !== void 0) {
1705
+ for (const v of element.Value) {
1706
+ results.push(v);
1707
+ }
1708
+ }
1709
+ }
1710
+ function enqueueSequenceItems(element, seg, segmentIndex, queue) {
1711
+ const items = getSequenceItems(element);
1712
+ if (items === void 0) return;
1713
+ if (seg.isWildcard === true) {
1714
+ for (const item of items) {
1715
+ if (isPlausibleModel(item)) {
1716
+ queue.push({ data: item, segmentIndex: segmentIndex + 1 });
1717
+ }
1718
+ }
1719
+ } else {
1720
+ const idx = seg.index ?? 0;
1721
+ const item = items[idx];
1722
+ if (isPlausibleModel(item)) {
1723
+ queue.push({ data: item, segmentIndex: segmentIndex + 1 });
1724
+ }
1725
+ }
1726
+ }
1727
+ var TAGS = {
1728
+ AccessionNumber: "00080050",
1729
+ PatientName: "00100010",
1730
+ PatientID: "00100020",
1731
+ StudyDate: "00080020",
1732
+ Modality: "00080060",
1733
+ SOPClassUID: "00080016",
1734
+ StudyInstanceUID: "0020000D",
1735
+ SeriesInstanceUID: "0020000E",
1736
+ SOPInstanceUID: "00080018",
1737
+ TransferSyntaxUID: "00020010"
1738
+ };
1739
+ var DicomDataset = class _DicomDataset {
1740
+ constructor(data) {
1741
+ __publicField(this, "data");
1742
+ this.data = data;
1743
+ }
1744
+ /**
1745
+ * Creates a DicomDataset from a DICOM JSON Model object.
1746
+ *
1747
+ * Performs structural validation only — verifies the input is a non-null object.
1748
+ *
1749
+ * @param json - A DICOM JSON Model object (typically from dcm2json)
1750
+ * @returns A Result containing the DicomDataset or an error
1751
+ */
1752
+ static fromJson(json) {
1753
+ if (json === null || json === void 0 || typeof json !== "object" || Array.isArray(json)) {
1754
+ return err(new Error("Invalid DICOM JSON: expected a non-null, non-array object"));
1755
+ }
1756
+ const obj = json;
1757
+ if (!isValidDicomJsonModel(obj)) {
1758
+ return err(new Error('Invalid DICOM JSON: every key must be an 8-char hex tag and every value must have a "vr" string property'));
1759
+ }
1760
+ return ok(new _DicomDataset(json));
1761
+ }
1762
+ /**
1763
+ * Gets the full DICOM JSON element for a tag.
1764
+ *
1765
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1766
+ * @returns Result containing the element or an error if not found
1767
+ */
1768
+ getElement(tag) {
1769
+ const norm = normalizeTag(tag);
1770
+ if (!norm.ok) return err(norm.error);
1771
+ const element = resolveElement(this.data, norm.value);
1772
+ if (element === void 0) return err(new Error(`Tag ${tag} not found`));
1773
+ return ok(element);
1774
+ }
1775
+ /**
1776
+ * Gets the Value array for a tag.
1777
+ *
1778
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1779
+ * @returns Result containing the readonly Value array or an error
1780
+ */
1781
+ getValue(tag) {
1782
+ const elemResult = this.getElement(tag);
1783
+ if (!elemResult.ok) return err(elemResult.error);
1784
+ const values = elemResult.value.Value;
1785
+ if (values === void 0) return err(new Error(`Tag ${tag} has no Value`));
1786
+ return ok(values);
1787
+ }
1788
+ /**
1789
+ * Gets the first element of the Value array for a tag.
1790
+ *
1791
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1792
+ * @returns Result containing the first value or an error
1793
+ */
1794
+ getFirstValue(tag) {
1795
+ const valResult = this.getValue(tag);
1796
+ if (!valResult.ok) return err(valResult.error);
1797
+ const first = valResult.value[0];
1798
+ if (first === void 0) return err(new Error(`Tag ${tag} Value array is empty`));
1799
+ return ok(first);
1800
+ }
1801
+ /**
1802
+ * Gets a tag value as a string with optional fallback.
1803
+ *
1804
+ * Returns the fallback (default empty string) if the tag is missing or has no value.
1805
+ * Handles PN (PersonName) values by extracting the Alphabetic component.
1806
+ *
1807
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1808
+ * @param fallback - Value to return if tag is missing (default: `''`)
1809
+ * @returns The string value or the fallback
1810
+ */
1811
+ getString(tag, fallback = "") {
1812
+ const elemResult = this.getElement(tag);
1813
+ if (!elemResult.ok) return fallback;
1814
+ const str = extractString(elemResult.value);
1815
+ return str.length > 0 ? str : fallback;
1816
+ }
1817
+ /**
1818
+ * Gets a tag value as a validated number.
1819
+ *
1820
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1821
+ * @returns Result containing the number or an error
1822
+ */
1823
+ getNumber(tag) {
1824
+ const elemResult = this.getElement(tag);
1825
+ if (!elemResult.ok) return err(elemResult.error);
1826
+ return extractNumber(elemResult.value);
1827
+ }
1828
+ /**
1829
+ * Gets all values of a tag as strings.
1830
+ *
1831
+ * Useful for multi-valued tags like CS (Code String).
1832
+ *
1833
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1834
+ * @returns Result containing the readonly string array or an error
1835
+ */
1836
+ getStrings(tag) {
1837
+ const elemResult = this.getElement(tag);
1838
+ if (!elemResult.ok) return err(elemResult.error);
1839
+ return ok(extractStrings(elemResult.value));
1840
+ }
1841
+ /**
1842
+ * Checks whether a tag exists in the dataset.
1843
+ *
1844
+ * @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
1845
+ * @returns `true` if the tag is present
1846
+ */
1847
+ hasTag(tag) {
1848
+ const norm = normalizeTag(tag);
1849
+ if (!norm.ok) return false;
1850
+ return resolveElement(this.data, norm.value) !== void 0;
1851
+ }
1852
+ /**
1853
+ * Gets an element by traversing a dotted tag path through sequences.
1854
+ *
1855
+ * @param path - A branded DicomTagPath, e.g. `(0040,A730)[0].(0040,A160)`
1856
+ * @returns Result containing the element at the path or an error
1857
+ */
1858
+ getElementAtPath(path2) {
1859
+ let segments;
1860
+ try {
1861
+ segments = tagPathToSegments(path2);
1862
+ } catch (e) {
1863
+ return err(stderrLib.stderr(e));
1864
+ }
1865
+ return traversePath(this.data, segments);
1866
+ }
1867
+ /**
1868
+ * Finds all values matching a path with wildcard `[*]` indices.
1869
+ *
1870
+ * Traverses all items in wildcard sequence positions using an iterative BFS queue.
1871
+ * The traversal is bounded to {@link MAX_TRAVERSAL_DEPTH} * 100 iterations (5 000).
1872
+ * For extremely large datasets this may truncate results silently; callers
1873
+ * needing completeness guarantees should verify dataset size independently.
1874
+ *
1875
+ * @param path - A branded DicomTagPath, e.g. `(0040,A730)[*].(0040,A160)`
1876
+ * @returns A readonly array of all matching values (may be empty)
1877
+ */
1878
+ findValues(path2) {
1879
+ let segments;
1880
+ try {
1881
+ segments = tagPathToSegments(path2);
1882
+ } catch {
1883
+ return [];
1884
+ }
1885
+ const result = collectWildcard(this.data, segments);
1886
+ return result.values;
1887
+ }
1888
+ // -----------------------------------------------------------------------
1889
+ // Convenience readonly getters
1890
+ // -----------------------------------------------------------------------
1891
+ /** Accession Number (0008,0050). */
1892
+ get accession() {
1893
+ return this.getString(TAGS.AccessionNumber);
1894
+ }
1895
+ /** Patient's Name (0010,0010). */
1896
+ get patientName() {
1897
+ return this.getString(TAGS.PatientName);
1898
+ }
1899
+ /** Patient ID (0010,0020). */
1900
+ get patientID() {
1901
+ return this.getString(TAGS.PatientID);
1902
+ }
1903
+ /** Study Date (0008,0020). */
1904
+ get studyDate() {
1905
+ return this.getString(TAGS.StudyDate);
1906
+ }
1907
+ /** Modality (0008,0060). */
1908
+ get modality() {
1909
+ return this.getString(TAGS.Modality);
1910
+ }
1911
+ /** SOP Class UID (0008,0016) as a branded SOPClassUID, or undefined if missing/invalid. */
1912
+ get sopClassUID() {
1913
+ const uid = this.getString(TAGS.SOPClassUID);
1914
+ if (uid.length === 0) return void 0;
1915
+ const result = createSOPClassUID(uid);
1916
+ return result.ok ? result.value : void 0;
1917
+ }
1918
+ /** Study Instance UID (0020,000D). */
1919
+ get studyInstanceUID() {
1920
+ return this.getString(TAGS.StudyInstanceUID);
1921
+ }
1922
+ /** Series Instance UID (0020,000E). */
1923
+ get seriesInstanceUID() {
1924
+ return this.getString(TAGS.SeriesInstanceUID);
1925
+ }
1926
+ /** SOP Instance UID (0008,0018). */
1927
+ get sopInstanceUID() {
1928
+ return this.getString(TAGS.SOPInstanceUID);
1929
+ }
1930
+ /** Transfer Syntax UID (0002,0010). */
1931
+ get transferSyntaxUID() {
1932
+ return this.getString(TAGS.TransferSyntaxUID);
1933
+ }
1934
+ };
1935
+ function killTree(pid) {
1936
+ try {
1937
+ kill__default.default(pid);
1938
+ } catch {
1939
+ }
1940
+ }
1941
+ async function execCommand(binary, args, options) {
1942
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1943
+ return new Promise((resolve) => {
1944
+ const child = child_process.spawn(binary, [...args], {
1945
+ cwd: options?.cwd,
1946
+ windowsHide: true,
1947
+ signal: options?.signal
1948
+ });
1949
+ wireSpawnListeners(child, timeoutMs, resolve);
1950
+ });
1951
+ }
1952
+ function wireSpawnListeners(child, timeoutMs, resolve) {
1953
+ let stdout = "";
1954
+ let stderr6 = "";
1955
+ let settled = false;
1956
+ const settle = (result) => {
1957
+ if (settled) return;
1958
+ settled = true;
1959
+ clearTimeout(timer);
1960
+ resolve(result);
1961
+ };
1962
+ const timer = setTimeout(() => {
1963
+ if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
1964
+ settle(err(new Error(`Process timed out after ${timeoutMs}ms`)));
1965
+ }, timeoutMs);
1966
+ child.stdout?.on("data", (chunk) => {
1967
+ stdout += String(chunk);
1968
+ if (stdout.length + stderr6.length > MAX_OUTPUT_BYTES) {
1969
+ if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
1970
+ settle(err(new Error(`Process output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
1971
+ }
1972
+ });
1973
+ child.stderr?.on("data", (chunk) => {
1974
+ stderr6 += String(chunk);
1975
+ if (stdout.length + stderr6.length > MAX_OUTPUT_BYTES) {
1976
+ if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
1977
+ settle(err(new Error(`Process output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
1978
+ }
1979
+ });
1980
+ child.on("error", (error) => {
1981
+ if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
1982
+ settle(err(new Error(`Process error: ${error.message}`)));
1983
+ });
1984
+ child.on("close", (code) => {
1985
+ settle(ok({ stdout, stderr: stderr6, exitCode: code ?? 1 }));
1986
+ });
1987
+ }
1988
+ async function spawnCommand(binary, args, options) {
1989
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1990
+ return new Promise((resolve) => {
1991
+ const child = child_process.spawn(binary, [...args], {
1992
+ cwd: options?.cwd,
1993
+ env: options?.env ? { ...process.env, ...options.env } : void 0,
1994
+ windowsHide: true,
1995
+ signal: options?.signal
1996
+ });
1997
+ wireSpawnListeners(child, timeoutMs, resolve);
1998
+ });
1999
+ }
2000
+ var PN_REPS = ["Alphabetic", "Ideographic", "Phonetic"];
2001
+ var ARRAY_TAG_NAMES = /* @__PURE__ */ new Set(["DicomAttribute", "Value", "PersonName", "Item"]);
2002
+ var KNOWN_VR_CODES = /* @__PURE__ */ new Set([
2003
+ "AE",
2004
+ "AS",
2005
+ "AT",
2006
+ "CS",
2007
+ "DA",
2008
+ "DS",
2009
+ "DT",
2010
+ "FD",
2011
+ "FL",
2012
+ "IS",
2013
+ "LO",
2014
+ "LT",
2015
+ "OB",
2016
+ "OD",
2017
+ "OF",
2018
+ "OL",
2019
+ "OV",
2020
+ "OW",
2021
+ "PN",
2022
+ "SH",
2023
+ "SL",
2024
+ "SQ",
2025
+ "SS",
2026
+ "ST",
2027
+ "SV",
2028
+ "TM",
2029
+ "UC",
2030
+ "UI",
2031
+ "UL",
2032
+ "UN",
2033
+ "UR",
2034
+ "US",
2035
+ "UT",
2036
+ "UV"
2037
+ ]);
2038
+ function buildPnString(comp) {
2039
+ const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""];
2040
+ let last = parts.length - 1;
2041
+ for (; last >= 0; last--) {
2042
+ if (parts[last] !== "") break;
2043
+ }
2044
+ return parts.slice(0, last + 1).join("^");
2045
+ }
2046
+ function toArray(val) {
2047
+ if (Array.isArray(val)) return val;
2048
+ if (val === void 0 || val === null) return [];
2049
+ return [val];
2050
+ }
2051
+ function convertPersonName(pnNode) {
2052
+ const result = {};
2053
+ if (typeof pnNode !== "object" || pnNode === null) return result;
2054
+ const pn = pnNode;
2055
+ for (const rep of PN_REPS) {
2056
+ const repNode = pn[rep];
2057
+ if (repNode !== void 0 && typeof repNode === "object" && repNode !== null) {
2058
+ const str = buildPnString(repNode);
2059
+ if (str.length > 0) {
2060
+ result[rep] = str;
2061
+ }
2062
+ }
2063
+ }
2064
+ return result;
2065
+ }
2066
+ function safeString(val) {
2067
+ if (typeof val === "string") return val;
2068
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
2069
+ return "";
2070
+ }
2071
+ function convertInlineBinary(attr, element) {
2072
+ element.InlineBinary = safeString(attr.InlineBinary);
2073
+ }
2074
+ function convertBulkDataURI(attr, element) {
2075
+ const bulkArray = toArray(attr.BulkDataURI);
2076
+ const firstBulk = bulkArray[0];
2077
+ if (typeof firstBulk === "object" && firstBulk !== null && "@_uri" in firstBulk) {
2078
+ element.BulkDataURI = safeString(firstBulk["@_uri"]);
2079
+ } else {
2080
+ element.BulkDataURI = safeString(firstBulk);
2081
+ }
2082
+ }
2083
+ function convertPNValue(attr, element) {
2084
+ const pnArray = toArray(attr.PersonName);
2085
+ const values = [];
2086
+ for (const pn of pnArray) {
2087
+ values.push(convertPersonName(pn));
2088
+ }
2089
+ if (values.length > 0) element.Value = values;
2090
+ }
2091
+ function convertSequence(attr, element) {
2092
+ const items = toArray(attr.Item);
2093
+ const values = [];
2094
+ for (const item of items) {
2095
+ if (typeof item !== "object" || item === null) continue;
2096
+ values.push(convertAttributes(item));
2097
+ }
2098
+ if (values.length > 0) element.Value = values;
2099
+ }
2100
+ function convertRegularValue(attr, element) {
2101
+ const valArray = toArray(attr.Value);
2102
+ const values = [];
2103
+ for (const v of valArray) {
2104
+ if (typeof v === "object" && v !== null && "#text" in v) {
2105
+ values.push(v["#text"]);
2106
+ } else {
2107
+ values.push(v);
2108
+ }
2109
+ }
2110
+ if (values.length > 0) element.Value = values;
2111
+ }
2112
+ function convertElement(attr) {
2113
+ const rawVr = attr["@_vr"];
2114
+ const vr = KNOWN_VR_CODES.has(rawVr) ? rawVr : "UN";
2115
+ const element = { vr };
2116
+ if (attr.InlineBinary !== void 0) {
2117
+ convertInlineBinary(attr, element);
2118
+ } else if (attr.BulkDataURI !== void 0) {
2119
+ convertBulkDataURI(attr, element);
2120
+ } else if (element.vr === "PN" && attr.PersonName !== void 0) {
2121
+ convertPNValue(attr, element);
2122
+ } else if (element.vr === "SQ" && attr.Item !== void 0) {
2123
+ convertSequence(attr, element);
2124
+ } else if (attr.Value !== void 0) {
2125
+ convertRegularValue(attr, element);
2126
+ }
2127
+ return Object.freeze(element);
2128
+ }
2129
+ function convertAttributes(obj) {
2130
+ const result = {};
2131
+ const attrs = toArray(obj["DicomAttribute"]);
2132
+ for (const attr of attrs) {
2133
+ if (typeof attr !== "object" || attr === null) continue;
2134
+ const xmlAttr = attr;
2135
+ const tag = xmlAttr["@_tag"];
2136
+ if (tag === void 0) continue;
2137
+ result[tag] = convertElement(xmlAttr);
2138
+ }
2139
+ return result;
2140
+ }
2141
+ var parser = new fastXmlParser.XMLParser({
2142
+ ignoreAttributes: false,
2143
+ attributeNamePrefix: "@_",
2144
+ parseTagValue: false,
2145
+ isArray: (name) => ARRAY_TAG_NAMES.has(name)
2146
+ });
2147
+ function xmlToJson(xml) {
2148
+ try {
2149
+ const parsed = parser.parse(xml);
2150
+ const root = parsed["NativeDicomModel"];
2151
+ if (root === void 0) {
2152
+ return err(new Error("Invalid dcm2xml output: missing NativeDicomModel root element"));
2153
+ }
2154
+ if (typeof root !== "object" || root === null) {
2155
+ return ok({});
2156
+ }
2157
+ return ok(convertAttributes(root));
2158
+ } catch (error) {
2159
+ return err(new Error(`Failed to parse dcm2xml XML: ${stderrLib.stderr(error).message}`));
2160
+ }
2161
+ }
2162
+
2163
+ // src/tools/_repairJson.ts
2164
+ var VALUE_ARRAY_PATTERN = /("Value"\s*:\s*\[)([\s\S]*?)(\])/g;
2165
+ var BARE_NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
2166
+ function quoteIfBareNumber(token) {
2167
+ const trimmed = token.trim();
2168
+ if (BARE_NUMBER.test(trimmed)) {
2169
+ return `"${trimmed}"`;
2170
+ }
2171
+ return trimmed;
2172
+ }
2173
+ function repairInner(inner) {
2174
+ const trimmed = inner.trim();
2175
+ if (trimmed.length === 0) return inner;
2176
+ const tokens = [];
2177
+ let current = "";
2178
+ let inString = false;
2179
+ for (let i = 0; i < trimmed.length; i++) {
2180
+ const ch = trimmed[i];
2181
+ if (ch === '"' && (i === 0 || trimmed[i - 1] !== "\\")) {
2182
+ inString = !inString;
2183
+ current += ch;
2184
+ } else if (ch === "," && !inString) {
2185
+ tokens.push(current);
2186
+ current = "";
2187
+ } else {
2188
+ current += ch;
2189
+ }
2190
+ }
2191
+ tokens.push(current);
2192
+ return tokens.map(quoteIfBareNumber).join(", ");
2193
+ }
2194
+ function repairJson(raw) {
2195
+ return raw.replace(VALUE_ARRAY_PATTERN, (_match, prefix, inner, suffix) => {
2196
+ return `${prefix}${repairInner(inner)}${suffix}`;
2197
+ });
2198
+ }
2199
+
2200
+ // src/tools/dcm2json.ts
2201
+ var Dcm2jsonOptionsSchema = zod.z.object({
2202
+ timeoutMs: zod.z.number().int().positive().optional(),
2203
+ signal: zod.z.instanceof(AbortSignal).optional(),
2204
+ directOnly: zod.z.boolean().optional()
2205
+ }).strict().optional();
2206
+ async function tryXmlPath(inputPath, timeoutMs, signal) {
2207
+ const xmlBinary = resolveBinary("dcm2xml");
2208
+ if (!xmlBinary.ok) {
2209
+ return err(xmlBinary.error);
2210
+ }
2211
+ const xmlResult = await execCommand(xmlBinary.value, ["-nat", inputPath], { timeoutMs, signal });
2212
+ if (!xmlResult.ok) {
2213
+ return err(xmlResult.error);
2214
+ }
2215
+ if (xmlResult.value.exitCode !== 0) {
2216
+ return err(createToolError("dcm2xml", ["-nat", inputPath], xmlResult.value.exitCode, xmlResult.value.stderr));
2217
+ }
2218
+ const jsonResult = xmlToJson(xmlResult.value.stdout);
2219
+ if (!jsonResult.ok) {
2220
+ return err(jsonResult.error);
2221
+ }
2222
+ return ok({ data: jsonResult.value, source: "xml" });
2223
+ }
2224
+ async function tryDirectPath(inputPath, timeoutMs, signal) {
2225
+ const jsonBinary = resolveBinary("dcm2json");
2226
+ if (!jsonBinary.ok) {
2227
+ return err(jsonBinary.error);
2228
+ }
2229
+ const result = await execCommand(jsonBinary.value, [inputPath], { timeoutMs, signal });
2230
+ if (!result.ok) {
2231
+ return err(result.error);
2232
+ }
2233
+ if (result.value.exitCode !== 0) {
2234
+ return err(createToolError("dcm2json", [inputPath], result.value.exitCode, result.value.stderr));
2235
+ }
2236
+ try {
2237
+ const repaired = repairJson(result.value.stdout);
2238
+ const data = JSON.parse(repaired);
2239
+ return ok({ data, source: "direct" });
2240
+ } catch (parseError) {
2241
+ return err(createToolError("dcm2json", [inputPath], 1, `Parse error: ${stderrLib.stderr(parseError).message}`));
2242
+ }
2243
+ }
2244
+ async function dcm2json(inputPath, options) {
2245
+ const validation = Dcm2jsonOptionsSchema.safeParse(options);
2246
+ if (!validation.success) {
2247
+ return err(createValidationError("dcm2json", validation.error));
2248
+ }
2249
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2250
+ const signal = options?.signal;
2251
+ if (options?.directOnly === true) {
2252
+ return tryDirectPath(inputPath, timeoutMs, signal);
2253
+ }
2254
+ const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal);
2255
+ if (xmlResult.ok) {
2256
+ return xmlResult;
2257
+ }
2258
+ return tryDirectPath(inputPath, timeoutMs, signal);
2259
+ }
2260
+ var TAG_OR_PATH_PATTERN = /^\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\](\.\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\])?)*)?$/;
2261
+ var TagModificationSchema = zod.z.object({
2262
+ tag: zod.z.string().regex(TAG_OR_PATH_PATTERN),
2263
+ value: zod.z.string()
2264
+ });
2265
+ var DcmodifyOptionsSchema = zod.z.object({
2266
+ timeoutMs: zod.z.number().int().positive().optional(),
2267
+ signal: zod.z.instanceof(AbortSignal).optional(),
2268
+ modifications: zod.z.array(TagModificationSchema).optional().default([]),
2269
+ erasures: zod.z.array(zod.z.string()).optional(),
2270
+ erasePrivateTags: zod.z.boolean().optional(),
2271
+ noBackup: zod.z.boolean().optional(),
2272
+ insertIfMissing: zod.z.boolean().optional(),
2273
+ ignoreMissingTags: zod.z.boolean().optional()
2274
+ }).strict().refine((data) => data.modifications.length > 0 || data.erasures !== void 0 && data.erasures.length > 0 || data.erasePrivateTags === true, {
2275
+ message: "At least one of modifications, erasures, or erasePrivateTags is required"
2276
+ });
2277
+ function buildArgs3(inputPath, options) {
2278
+ const args = [];
2279
+ if (options.noBackup !== false) {
2280
+ args.push("-nb");
2281
+ }
2282
+ if (options.ignoreMissingTags === true) {
2283
+ args.push("-imt");
2284
+ }
2285
+ const flag = options.insertIfMissing === true ? "-i" : "-m";
2286
+ const modifications = options.modifications ?? [];
2287
+ for (const mod of modifications) {
2288
+ args.push(flag, `${mod.tag}=${mod.value}`);
2289
+ }
2290
+ if (options.erasures !== void 0) {
2291
+ for (const erasure of options.erasures) {
2292
+ args.push("-e", erasure);
2293
+ }
2294
+ }
2295
+ if (options.erasePrivateTags === true) {
2296
+ args.push("-ep");
2297
+ }
2298
+ args.push(inputPath);
2299
+ return args;
2300
+ }
2301
+ async function dcmodify(inputPath, options) {
2302
+ const validation = DcmodifyOptionsSchema.safeParse(options);
2303
+ if (!validation.success) {
2304
+ return err(createValidationError("dcmodify", validation.error));
2305
+ }
2306
+ const binaryResult = resolveBinary("dcmodify");
2307
+ if (!binaryResult.ok) {
2308
+ return err(binaryResult.error);
2309
+ }
2310
+ const args = buildArgs3(inputPath, options);
2311
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2312
+ const result = await spawnCommand(binaryResult.value, args, {
2313
+ timeoutMs,
2314
+ signal: options.signal
2315
+ });
2316
+ if (!result.ok) {
2317
+ return err(result.error);
2318
+ }
2319
+ if (result.value.exitCode !== 0) {
2320
+ return err(createToolError("dcmodify", args, result.value.exitCode, result.value.stderr));
2321
+ }
2322
+ return ok({ filePath: inputPath });
2323
+ }
2324
+ async function applyModifications(filePath, changeset, options) {
2325
+ const modifications = changeset.toModifications();
2326
+ const erasures = changeset.toErasureArgs();
2327
+ const hasErasures = erasures.length > 0 || changeset.erasePrivate;
2328
+ const result = await dcmodify(filePath, {
2329
+ modifications: modifications.length > 0 ? modifications : void 0,
2330
+ erasures: erasures.length > 0 ? erasures : void 0,
2331
+ erasePrivateTags: changeset.erasePrivate || void 0,
2332
+ insertIfMissing: true,
2333
+ ignoreMissingTags: hasErasures || void 0,
2334
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2335
+ signal: options.signal
2336
+ });
2337
+ if (!result.ok) return err(result.error);
2338
+ return ok(void 0);
2339
+ }
2340
+ async function copyFileSafe(source, dest) {
2341
+ return stderrLib.tryCatch(
2342
+ () => fs.copyFile(source, dest),
2343
+ (e) => new Error(`Failed to copy file: ${e.message}`)
2344
+ );
2345
+ }
2346
+ async function statFileSize(path2) {
2347
+ return stderrLib.tryCatch(
2348
+ async () => (await fs.stat(path2)).size,
2349
+ (e) => new Error(`Failed to stat file: ${e.message}`)
2350
+ );
2351
+ }
2352
+ async function unlinkFile(path2) {
2353
+ return stderrLib.tryCatch(
2354
+ () => fs.unlink(path2),
2355
+ (e) => new Error(`Failed to delete file: ${e.message}`)
2356
+ );
2357
+ }
2358
+
2359
+ // src/dicom/DicomInstance.ts
2360
+ var DicomInstance = class _DicomInstance {
2361
+ constructor(dataset, changes, filePath, metadata) {
2362
+ __publicField(this, "dicomDataset");
2363
+ __publicField(this, "changeSet");
2364
+ __publicField(this, "filepath");
2365
+ __publicField(this, "meta");
2366
+ this.dicomDataset = dataset;
2367
+ this.changeSet = changes;
2368
+ this.filepath = filePath;
2369
+ this.meta = metadata;
2370
+ }
2371
+ // -----------------------------------------------------------------------
2372
+ // Factories
2373
+ // -----------------------------------------------------------------------
2374
+ /**
2375
+ * Opens a DICOM file and creates a DicomInstance.
2376
+ *
2377
+ * @param path - Filesystem path to the DICOM file
2378
+ * @param options - Timeout and abort options
2379
+ * @returns A Result containing the DicomInstance or an error
2380
+ */
2381
+ static async open(path2, options) {
2382
+ const filePathResult = createDicomFilePath(path2);
2383
+ if (!filePathResult.ok) return err(filePathResult.error);
2384
+ const jsonResult = await dcm2json(path2, {
2385
+ timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2386
+ signal: options?.signal
2387
+ });
2388
+ if (!jsonResult.ok) return err(jsonResult.error);
2389
+ const datasetResult = DicomDataset.fromJson(jsonResult.value.data);
2390
+ if (!datasetResult.ok) return err(datasetResult.error);
2391
+ return ok(new _DicomInstance(datasetResult.value, ChangeSet.empty(), filePathResult.value, /* @__PURE__ */ new Map()));
2392
+ }
2393
+ /**
2394
+ * Creates a DicomInstance from an existing DicomDataset.
2395
+ *
2396
+ * @param dataset - The DicomDataset to wrap
2397
+ * @param filePath - Optional file path (e.g. if the dataset came from a file)
2398
+ * @returns A Result containing the DicomInstance
2399
+ */
2400
+ static fromDataset(dataset, filePath) {
2401
+ let fp;
2402
+ if (filePath !== void 0) {
2403
+ const fpResult = createDicomFilePath(filePath);
2404
+ if (!fpResult.ok) return err(fpResult.error);
2405
+ fp = fpResult.value;
2406
+ }
2407
+ return ok(new _DicomInstance(dataset, ChangeSet.empty(), fp, /* @__PURE__ */ new Map()));
2408
+ }
2409
+ // -----------------------------------------------------------------------
2410
+ // Read accessors (delegate to DicomDataset)
2411
+ // -----------------------------------------------------------------------
2412
+ /** The underlying immutable DICOM dataset. */
2413
+ get dataset() {
2414
+ return this.dicomDataset;
2415
+ }
2416
+ /** The pending change set. */
2417
+ get changes() {
2418
+ return this.changeSet;
2419
+ }
2420
+ /** Whether there are unsaved changes. */
2421
+ get hasUnsavedChanges() {
2422
+ return !this.changeSet.isEmpty;
2423
+ }
2424
+ /** The file path, or undefined if this instance has no associated file. */
2425
+ get filePath() {
2426
+ return this.filepath;
2427
+ }
2428
+ /** Patient's Name (0010,0010). */
2429
+ get patientName() {
2430
+ return this.dicomDataset.patientName;
2431
+ }
2432
+ /** Patient ID (0010,0020). */
2433
+ get patientID() {
2434
+ return this.dicomDataset.patientID;
2435
+ }
2436
+ /** Study Date (0008,0020). */
2437
+ get studyDate() {
2438
+ return this.dicomDataset.studyDate;
2439
+ }
2440
+ /** Modality (0008,0060). */
2441
+ get modality() {
2442
+ return this.dicomDataset.modality;
2443
+ }
2444
+ /** Accession Number (0008,0050). */
2445
+ get accession() {
2446
+ return this.dicomDataset.accession;
2447
+ }
2448
+ /** SOP Class UID (0008,0016). */
2449
+ get sopClassUID() {
2450
+ return this.dicomDataset.sopClassUID;
2451
+ }
2452
+ /** Study Instance UID (0020,000D). */
2453
+ get studyInstanceUID() {
2454
+ return this.dicomDataset.studyInstanceUID;
2455
+ }
2456
+ /** Series Instance UID (0020,000E). */
2457
+ get seriesInstanceUID() {
2458
+ return this.dicomDataset.seriesInstanceUID;
2459
+ }
2460
+ /** SOP Instance UID (0008,0018). */
2461
+ get sopInstanceUID() {
2462
+ return this.dicomDataset.sopInstanceUID;
2463
+ }
2464
+ /** Transfer Syntax UID (0002,0010). */
2465
+ get transferSyntaxUID() {
2466
+ return this.dicomDataset.transferSyntaxUID;
2467
+ }
2468
+ /**
2469
+ * Gets a tag value as a string with optional fallback.
2470
+ *
2471
+ * @param tag - A DICOM tag, e.g. `'(0010,0010)'` or `'00100010'`
2472
+ * @param fallback - Value to return if tag is missing (default: `''`)
2473
+ */
2474
+ getString(tag, fallback = "") {
2475
+ return this.dicomDataset.getString(tag, fallback);
2476
+ }
2477
+ /**
2478
+ * Gets a tag value as a number.
2479
+ *
2480
+ * @param tag - A DICOM tag, e.g. `'(0020,0013)'`
2481
+ */
2482
+ getNumber(tag) {
2483
+ return this.dicomDataset.getNumber(tag);
2484
+ }
2485
+ /** Checks whether a tag exists in the dataset. */
2486
+ hasTag(tag) {
2487
+ return this.dicomDataset.hasTag(tag);
2488
+ }
2489
+ /**
2490
+ * Finds all values matching a wildcard path.
2491
+ *
2492
+ * @param path - A DicomTagPath with optional wildcard indices
2493
+ */
2494
+ findValues(path2) {
2495
+ return this.dicomDataset.findValues(path2);
2496
+ }
2497
+ // -----------------------------------------------------------------------
2498
+ // Write methods (return new instance)
2499
+ // -----------------------------------------------------------------------
2500
+ /**
2501
+ * Sets a tag value, returning a new DicomInstance.
2502
+ *
2503
+ * @param path - The DICOM tag path (e.g. `'(0010,0010)'`)
2504
+ * @param value - The new value
2505
+ */
2506
+ setTag(path2, value) {
2507
+ return new _DicomInstance(this.dicomDataset, this.changeSet.setTag(path2, value), this.filepath, this.meta);
2508
+ }
2509
+ /**
2510
+ * Erases a tag, returning a new DicomInstance.
2511
+ *
2512
+ * @param path - The DICOM tag path to erase
2513
+ */
2514
+ eraseTag(path2) {
2515
+ return new _DicomInstance(this.dicomDataset, this.changeSet.eraseTag(path2), this.filepath, this.meta);
2516
+ }
2517
+ /** Erases all private tags, returning a new DicomInstance. */
2518
+ erasePrivateTags() {
2519
+ return new _DicomInstance(this.dicomDataset, this.changeSet.erasePrivateTags(), this.filepath, this.meta);
2520
+ }
2521
+ /** Sets Patient's Name (0010,0010). */
2522
+ setPatientName(value) {
2523
+ return this.setTag("(0010,0010)", value);
2524
+ }
2525
+ /** Sets Patient ID (0010,0020). */
2526
+ setPatientID(value) {
2527
+ return this.setTag("(0010,0020)", value);
2528
+ }
2529
+ /** Sets Study Date (0008,0020). */
2530
+ setStudyDate(value) {
2531
+ return this.setTag("(0008,0020)", value);
2532
+ }
2533
+ /** Sets Modality (0008,0060). */
2534
+ setModality(value) {
2535
+ return this.setTag("(0008,0060)", value);
2536
+ }
2537
+ /** Sets Accession Number (0008,0050). */
2538
+ setAccessionNumber(value) {
2539
+ return this.setTag("(0008,0050)", value);
2540
+ }
2541
+ /** Sets Study Description (0008,1030). */
2542
+ setStudyDescription(value) {
2543
+ return this.setTag("(0008,1030)", value);
2544
+ }
2545
+ /** Sets Series Description (0008,103E). */
2546
+ setSeriesDescription(value) {
2547
+ return this.setTag("(0008,103E)", value);
2548
+ }
2549
+ /** Sets Institution Name (0008,0080). */
2550
+ setInstitutionName(value) {
2551
+ return this.setTag("(0008,0080)", value);
2552
+ }
2553
+ /**
2554
+ * Transforms a tag value using a function.
2555
+ *
2556
+ * The function receives the current string value (or undefined if tag is missing)
2557
+ * and returns the new value. Returns a new DicomInstance with the modification.
2558
+ *
2559
+ * @param path - The DICOM tag path
2560
+ * @param fn - Transform function receiving the current value
2561
+ */
2562
+ transformTag(path2, fn) {
2563
+ const current = this.dicomDataset.getString(path2);
2564
+ const newValue = fn(current.length > 0 ? current : void 0);
2565
+ return this.setTag(path2, newValue);
2566
+ }
2567
+ /**
2568
+ * Sets multiple tags at once, returning a new DicomInstance.
2569
+ *
2570
+ * @param entries - A record of tag path → value pairs
2571
+ */
2572
+ setBatch(entries) {
2573
+ return new _DicomInstance(this.dicomDataset, this.changeSet.setBatch(entries), this.filepath, this.meta);
2574
+ }
2575
+ /**
2576
+ * Returns a new DicomInstance with the given changes merged into pending changes.
2577
+ *
2578
+ * @param changes - A ChangeSet to merge with existing pending changes
2579
+ * @returns A new DicomInstance with accumulated changes
2580
+ */
2581
+ withChanges(changes) {
2582
+ return new _DicomInstance(this.dicomDataset, this.changeSet.merge(changes), this.filepath, this.meta);
2583
+ }
2584
+ /**
2585
+ * Returns a new DicomInstance pointing to a different file path.
2586
+ *
2587
+ * Preserves the dataset, pending changes, and metadata.
2588
+ *
2589
+ * @param newPath - The new filesystem path (validated via createDicomFilePath)
2590
+ * @returns A new DicomInstance with the updated path
2591
+ * @throws If the path is invalid
2592
+ */
2593
+ withFilePath(newPath) {
2594
+ const result = createDicomFilePath(newPath);
2595
+ if (!result.ok) throw result.error;
2596
+ return new _DicomInstance(this.dicomDataset, this.changeSet, result.value, this.meta);
2597
+ }
2598
+ // -----------------------------------------------------------------------
2599
+ // File I/O
2600
+ // -----------------------------------------------------------------------
2601
+ /**
2602
+ * Applies pending changes to the file in-place.
2603
+ *
2604
+ * Requires that the instance has an associated file path.
2605
+ *
2606
+ * @param options - Timeout and abort options
2607
+ */
2608
+ async applyChanges(options) {
2609
+ if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
2610
+ if (this.changeSet.isEmpty) return ok(void 0);
2611
+ return applyModifications(this.filepath, this.changeSet, options ?? {});
2612
+ }
2613
+ /**
2614
+ * Copies the file to a new path and applies pending changes to the copy.
2615
+ *
2616
+ * Returns a new DicomInstance pointing to the output path.
2617
+ *
2618
+ * @param outputPath - Destination filesystem path
2619
+ * @param options - Timeout and abort options
2620
+ */
2621
+ async writeAs(outputPath, options) {
2622
+ if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
2623
+ const outPathResult = createDicomFilePath(outputPath);
2624
+ if (!outPathResult.ok) return err(outPathResult.error);
2625
+ const copyResult = await copyFileSafe(this.filepath, outputPath);
2626
+ if (!copyResult.ok) return err(copyResult.error);
2627
+ if (!this.changeSet.isEmpty) {
2628
+ const applyResult = await applyModifications(outPathResult.value, this.changeSet, options ?? {});
2629
+ if (!applyResult.ok) {
2630
+ await unlinkFile(outputPath);
2631
+ return err(applyResult.error);
2632
+ }
2633
+ }
2634
+ return ok(new _DicomInstance(this.dicomDataset, ChangeSet.empty(), outPathResult.value, this.meta));
2635
+ }
2636
+ /**
2637
+ * Gets the file size in bytes.
2638
+ *
2639
+ * @returns A Result containing the size or an error
2640
+ */
2641
+ async fileSize() {
2642
+ if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
2643
+ return statFileSize(this.filepath);
2644
+ }
2645
+ /**
2646
+ * Deletes the associated file from the filesystem.
2647
+ *
2648
+ * @returns A Result indicating success or failure
2649
+ */
2650
+ async unlink() {
2651
+ if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
2652
+ return unlinkFile(this.filepath);
2653
+ }
2654
+ // -----------------------------------------------------------------------
2655
+ // Metadata (non-DICOM app context)
2656
+ // -----------------------------------------------------------------------
2657
+ /**
2658
+ * Returns a new DicomInstance with application metadata attached.
2659
+ *
2660
+ * Metadata is not stored in the DICOM file — it's for application context
2661
+ * (e.g. tracking source association, processing status, etc.).
2662
+ *
2663
+ * @param key - Metadata key
2664
+ * @param value - Metadata value
2665
+ */
2666
+ withMetadata(key, value) {
2667
+ const newMeta = new Map(this.meta);
2668
+ newMeta.set(key, value);
2669
+ return new _DicomInstance(this.dicomDataset, this.changeSet, this.filepath, newMeta);
2670
+ }
2671
+ /**
2672
+ * Gets application metadata by key.
2673
+ *
2674
+ * @param key - Metadata key
2675
+ * @returns The metadata value or undefined
2676
+ */
2677
+ getMetadata(key) {
2678
+ return this.meta.get(key);
2679
+ }
2680
+ };
2681
+
2682
+ // src/servers/DicomReceiver.ts
2683
+ var DEFAULT_MIN_POOL_SIZE = 2;
2684
+ var DEFAULT_MAX_POOL_SIZE = 10;
2685
+ var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2686
+ var CONNECTION_RETRY_INTERVAL_MS = 500;
2687
+ var MAX_CONNECTION_RETRIES = 200;
2688
+ var DicomReceiverOptionsSchema = zod.z.object({
2689
+ port: zod.z.number().int().min(1).max(65535),
2690
+ storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
2691
+ aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
2692
+ minPoolSize: zod.z.number().int().min(1).max(100).optional(),
2693
+ maxPoolSize: zod.z.number().int().min(1).max(100).optional(),
2694
+ connectionTimeoutMs: zod.z.number().int().positive().optional(),
2695
+ configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
2696
+ configProfile: zod.z.string().min(1).optional(),
2697
+ signal: zod.z.instanceof(AbortSignal).optional()
2698
+ }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
2699
+ message: "minPoolSize must be <= maxPoolSize"
2700
+ });
2701
+ function allocatePort() {
2702
+ return new Promise((resolve) => {
2703
+ const server = net__namespace.createServer();
2704
+ server.listen(0, "127.0.0.1", () => {
2705
+ const addr = server.address();
2706
+ if (addr === null || typeof addr === "string") {
2707
+ server.close(() => resolve(err(new Error("Failed to allocate port"))));
2708
+ return;
2709
+ }
2710
+ const port = addr.port;
2711
+ server.close(() => resolve(ok(port)));
2712
+ });
2713
+ server.on("error", (e) => {
2714
+ resolve(err(new Error(`Port allocation failed: ${e.message}`)));
2715
+ });
2716
+ });
2717
+ }
2718
+ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2719
+ constructor(options) {
2720
+ super();
2721
+ __publicField(this, "options");
2722
+ __publicField(this, "minPoolSize");
2723
+ __publicField(this, "maxPoolSize");
2724
+ __publicField(this, "connectionTimeoutMs");
2725
+ __publicField(this, "workers", /* @__PURE__ */ new Map());
2726
+ __publicField(this, "tcpServer");
2727
+ __publicField(this, "associationCounter", 0);
2728
+ __publicField(this, "started", false);
2729
+ __publicField(this, "stopping", false);
2730
+ __publicField(this, "abortHandler");
2731
+ this.setMaxListeners(20);
2732
+ this.on("error", () => {
2733
+ });
2734
+ this.options = options;
2735
+ this.minPoolSize = options.minPoolSize ?? DEFAULT_MIN_POOL_SIZE;
2736
+ this.maxPoolSize = options.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE;
2737
+ this.connectionTimeoutMs = options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
2738
+ }
2739
+ // -----------------------------------------------------------------------
2740
+ // Public API
2741
+ // -----------------------------------------------------------------------
2742
+ /**
2743
+ * Creates a new DicomReceiver instance.
2744
+ *
2745
+ * @param options - Configuration options
2746
+ * @returns A Result containing the instance or a validation error
2747
+ */
2748
+ static create(options) {
2749
+ const validation = DicomReceiverOptionsSchema.safeParse(options);
2750
+ if (!validation.success) {
2751
+ return err(createValidationError("DicomReceiver", validation.error));
2752
+ }
2753
+ return ok(new _DicomReceiver(options));
2754
+ }
2755
+ /**
2756
+ * Starts the TCP proxy and spawns the initial worker pool.
2757
+ *
2758
+ * @returns A Result indicating success or failure
2759
+ */
2760
+ async start() {
2761
+ if (this.started) {
2762
+ return err(new Error("DicomReceiver: already started"));
2763
+ }
2764
+ this.started = true;
2765
+ const storageDirResult = await ensureDirectory(this.options.storageDir);
2766
+ if (!storageDirResult.ok) return storageDirResult;
2767
+ const spawnResults = await this.spawnWorkers(this.minPoolSize);
2768
+ if (!spawnResults.ok) return spawnResults;
2769
+ const listenResult = await this.startTcpProxy();
2770
+ if (!listenResult.ok) return listenResult;
2771
+ if (this.options.signal !== void 0) {
2772
+ this.wireAbortSignal(this.options.signal);
2773
+ }
2774
+ return ok(void 0);
2775
+ }
2776
+ /**
2777
+ * Stops the TCP proxy and all workers.
2778
+ */
2779
+ async stop() {
2780
+ if (!this.started || this.stopping) {
2781
+ return;
2782
+ }
2783
+ this.stopping = true;
2784
+ if (this.options.signal !== void 0 && this.abortHandler !== void 0) {
2785
+ this.options.signal.removeEventListener("abort", this.abortHandler);
2786
+ }
2787
+ await this.closeTcpProxy();
2788
+ const stopPromises = [];
2789
+ for (const worker of this.workers.values()) {
2790
+ stopPromises.push(this.stopWorker(worker));
2791
+ }
2792
+ await Promise.all(stopPromises);
2793
+ this.workers.clear();
2794
+ this.started = false;
2795
+ this.stopping = false;
2796
+ }
2797
+ /**
2798
+ * Registers a typed listener for a DicomReceiver-specific event.
2799
+ *
2800
+ * @param event - The event name from DicomReceiverEventMap
2801
+ * @param listener - Callback receiving typed event data
2802
+ * @returns this for chaining
2803
+ */
2804
+ onEvent(event, listener) {
2805
+ return this.on(event, listener);
2806
+ }
2807
+ /**
2808
+ * Registers a listener for received files.
2809
+ *
2810
+ * @param listener - Callback receiving file data
2811
+ * @returns this for chaining
2812
+ */
2813
+ onFileReceived(listener) {
2814
+ return this.on("FILE_RECEIVED", listener);
2815
+ }
2816
+ /**
2817
+ * Registers a listener for completed associations.
2818
+ *
2819
+ * @param listener - Callback receiving association data
2820
+ * @returns this for chaining
2821
+ */
2822
+ onAssociationComplete(listener) {
2823
+ return this.on("ASSOCIATION_COMPLETE", listener);
2824
+ }
2825
+ /** Current pool status. */
2826
+ get poolStatus() {
2827
+ let idle = 0;
2828
+ let busy = 0;
2829
+ for (const w of this.workers.values()) {
2830
+ if (w.state === "idle") idle++;
2831
+ else busy++;
2832
+ }
2833
+ return { idle, busy, total: this.workers.size };
2834
+ }
2835
+ // -----------------------------------------------------------------------
2836
+ // TCP proxy
2837
+ // -----------------------------------------------------------------------
2838
+ /** Starts the TCP proxy on the configured port. */
2839
+ startTcpProxy() {
2840
+ return new Promise((resolve) => {
2841
+ this.tcpServer = net__namespace.createServer((socket) => {
2842
+ void this.handleConnection(socket);
2843
+ });
2844
+ this.tcpServer.on("error", (e) => {
2845
+ if (!this.started) {
2846
+ resolve(err(new Error(`DicomReceiver: TCP proxy failed: ${e.message}`)));
2847
+ } else {
2848
+ this.emit("error", { error: e instanceof Error ? e : new Error(String(e)) });
2849
+ }
2850
+ });
2851
+ this.tcpServer.listen(this.options.port, () => {
2852
+ resolve(ok(void 0));
2853
+ });
2854
+ });
2855
+ }
2856
+ /** Closes the TCP proxy server. */
2857
+ closeTcpProxy() {
2858
+ return new Promise((resolve) => {
2859
+ if (this.tcpServer === void 0) {
2860
+ resolve();
2861
+ return;
2862
+ }
2863
+ this.tcpServer.close(() => resolve());
2864
+ });
2865
+ }
2866
+ // -----------------------------------------------------------------------
2867
+ // Connection routing
2868
+ // -----------------------------------------------------------------------
2869
+ /** Routes an incoming connection to an idle worker. */
2870
+ async handleConnection(remoteSocket) {
2871
+ remoteSocket.pause();
2872
+ const worker = await this.findIdleWorker();
2873
+ if (worker === void 0) {
2874
+ remoteSocket.destroy(new Error("DicomReceiver: no idle worker available"));
2875
+ this.emit("error", { error: new Error("DicomReceiver: connection rejected \u2014 pool exhausted") });
2876
+ return;
2877
+ }
2878
+ this.associationCounter++;
2879
+ const associationId = `assoc-${String(this.associationCounter)}`;
2880
+ const associationDir = path__namespace.join(this.options.storageDir, associationId);
2881
+ const mkdirResult = await ensureDirectory(associationDir);
2882
+ if (!mkdirResult.ok) {
2883
+ remoteSocket.destroy();
2884
+ this.emit("error", { error: mkdirResult.error });
2885
+ return;
2886
+ }
2887
+ worker.state = "busy";
2888
+ worker.associationId = associationId;
2889
+ worker.associationDir = associationDir;
2890
+ worker.files = [];
2891
+ worker.fileSizes = [];
2892
+ worker.startAt = Date.now();
2893
+ this.pipeConnection(worker, remoteSocket);
2894
+ void this.replenishPool();
2895
+ }
2896
+ /** Finds an idle worker, retrying up to connectionTimeoutMs. */
2897
+ async findIdleWorker() {
2898
+ const idle = this.getIdleWorker();
2899
+ if (idle !== void 0) return idle;
2900
+ const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
2901
+ for (let i = 0; i < maxRetries; i++) {
2902
+ await delay(CONNECTION_RETRY_INTERVAL_MS);
2903
+ const found = this.getIdleWorker();
2904
+ if (found !== void 0) return found;
2905
+ if (this.stopping) return void 0;
1152
2906
  }
1153
- const args = buildArgs3(options);
1154
- const parser = new LineParser();
2907
+ return void 0;
2908
+ }
2909
+ /** Returns the first idle worker, or undefined. */
2910
+ getIdleWorker() {
2911
+ for (const w of this.workers.values()) {
2912
+ if (w.state === "idle") return w;
2913
+ }
2914
+ return void 0;
2915
+ }
2916
+ /** Pipes remote socket bidirectionally to the worker's port. */
2917
+ pipeConnection(worker, remoteSocket) {
2918
+ const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
2919
+ worker.remoteSocket = remoteSocket;
2920
+ worker.workerSocket = workerSocket;
2921
+ remoteSocket.pipe(workerSocket);
2922
+ workerSocket.pipe(remoteSocket);
2923
+ remoteSocket.resume();
2924
+ const cleanup = () => {
2925
+ remoteSocket.unpipe(workerSocket);
2926
+ workerSocket.unpipe(remoteSocket);
2927
+ if (!remoteSocket.destroyed) remoteSocket.destroy();
2928
+ if (!workerSocket.destroyed) workerSocket.destroy();
2929
+ };
2930
+ remoteSocket.on("error", cleanup);
2931
+ workerSocket.on("error", cleanup);
2932
+ remoteSocket.on("close", cleanup);
2933
+ workerSocket.on("close", cleanup);
2934
+ }
2935
+ // -----------------------------------------------------------------------
2936
+ // Worker pool management
2937
+ // -----------------------------------------------------------------------
2938
+ /** Spawns `count` new workers and adds them to the pool. */
2939
+ async spawnWorkers(count) {
2940
+ const promises = [];
2941
+ for (let i = 0; i < count; i++) {
2942
+ promises.push(this.spawnWorker());
2943
+ }
2944
+ const results = await Promise.all(promises);
2945
+ for (const result of results) {
2946
+ if (!result.ok) return err(result.error);
2947
+ }
2948
+ return ok(void 0);
2949
+ }
2950
+ /** Spawns a single Dcmrecv worker with an ephemeral port. */
2951
+ async spawnWorker() {
2952
+ const portResult = await allocatePort();
2953
+ if (!portResult.ok) return portResult;
2954
+ const port = portResult.value;
2955
+ const tempDir = path__namespace.join(os__namespace.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
2956
+ const mkdirResult = await ensureDirectory(tempDir);
2957
+ if (!mkdirResult.ok) return mkdirResult;
2958
+ const createResult = Dcmrecv.create({
2959
+ port,
2960
+ aeTitle: this.options.aeTitle ?? "DCMRECV",
2961
+ outputDirectory: tempDir,
2962
+ configFile: this.options.configFile,
2963
+ configProfile: this.options.configProfile
2964
+ });
2965
+ if (!createResult.ok) return createResult;
2966
+ const dcmrecv = createResult.value;
2967
+ const worker = {
2968
+ dcmrecv,
2969
+ port,
2970
+ tempDir,
2971
+ state: "idle",
2972
+ associationId: void 0,
2973
+ associationDir: void 0,
2974
+ files: [],
2975
+ fileSizes: [],
2976
+ startAt: void 0,
2977
+ remoteSocket: void 0,
2978
+ workerSocket: void 0
2979
+ };
2980
+ this.wireWorkerEvents(worker);
2981
+ const startResult = await dcmrecv.start();
2982
+ if (!startResult.ok) {
2983
+ return err(new Error(`DicomReceiver: worker start failed on port ${String(port)}: ${startResult.error.message}`));
2984
+ }
2985
+ this.workers.set(port, worker);
2986
+ return ok(worker);
2987
+ }
2988
+ /** Stops a single worker: stop process, clean temp dir, remove from pool. */
2989
+ async stopWorker(worker) {
2990
+ if (worker.remoteSocket !== void 0 && !worker.remoteSocket.destroyed) {
2991
+ worker.remoteSocket.destroy();
2992
+ }
2993
+ if (worker.workerSocket !== void 0 && !worker.workerSocket.destroyed) {
2994
+ worker.workerSocket.destroy();
2995
+ }
2996
+ await worker.dcmrecv.stop();
2997
+ worker.dcmrecv[Symbol.dispose]();
2998
+ await removeDirSafe(worker.tempDir);
2999
+ this.workers.delete(worker.port);
3000
+ }
3001
+ /** Pre-emptively spawns workers to keep idle count >= minPoolSize. */
3002
+ async replenishPool() {
3003
+ const status = this.poolStatus;
3004
+ const needed = this.minPoolSize - status.idle;
3005
+ const capacity = this.maxPoolSize - status.total;
3006
+ const toSpawn = Math.min(needed, capacity);
3007
+ if (toSpawn <= 0) return;
3008
+ const promises = [];
3009
+ for (let i = 0; i < toSpawn; i++) {
3010
+ promises.push(this.spawnWorker());
3011
+ }
3012
+ const results = await Promise.all(promises);
3013
+ for (const result of results) {
3014
+ if (!result.ok) {
3015
+ this.emit("error", { error: result.error });
3016
+ }
3017
+ }
3018
+ }
3019
+ /** Stops excess idle workers when idle count > minPoolSize + 2. */
3020
+ async scaleDown() {
3021
+ const idleWorkers = [];
3022
+ for (const w of this.workers.values()) {
3023
+ if (w.state === "idle") idleWorkers.push(w);
3024
+ }
3025
+ const excess = idleWorkers.length - (this.minPoolSize + 2);
3026
+ if (excess <= 0) return;
3027
+ const toStop = idleWorkers.slice(0, excess);
3028
+ const promises = [];
3029
+ for (const w of toStop) {
3030
+ promises.push(this.stopWorker(w));
3031
+ }
3032
+ await Promise.all(promises);
3033
+ }
3034
+ // -----------------------------------------------------------------------
3035
+ // Worker event wiring
3036
+ // -----------------------------------------------------------------------
3037
+ /** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
3038
+ wireWorkerEvents(worker) {
3039
+ this.wireFileReceived(worker);
3040
+ this.wireAssociationComplete(worker);
3041
+ }
3042
+ /** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
3043
+ wireFileReceived(worker) {
3044
+ worker.dcmrecv.onFileReceived((data) => {
3045
+ void this.handleFileReceived(worker, data);
3046
+ });
3047
+ }
3048
+ /** Moves a received file, opens it as DicomInstance, and emits FILE_RECEIVED. */
3049
+ async handleFileReceived(worker, data) {
3050
+ if (worker.associationDir === void 0 || worker.associationId === void 0) return;
3051
+ const srcPath = data.filePath;
3052
+ const destPath = path__namespace.join(worker.associationDir, path__namespace.basename(srcPath));
3053
+ const assocId = worker.associationId;
3054
+ const assocDir = worker.associationDir;
3055
+ const moveResult = await moveFile(srcPath, destPath);
3056
+ const finalPath = moveResult.ok ? destPath : srcPath;
3057
+ worker.files.push(finalPath);
3058
+ worker.fileSizes.push(await statFileSafe(finalPath));
3059
+ const openResult = await DicomInstance.open(finalPath);
3060
+ if (!openResult.ok) {
3061
+ this.emit("error", {
3062
+ error: openResult.error,
3063
+ filePath: finalPath,
3064
+ associationId: assocId,
3065
+ associationDir: assocDir,
3066
+ callingAE: data.callingAE,
3067
+ calledAE: data.calledAE,
3068
+ source: data.source
3069
+ });
3070
+ return;
3071
+ }
3072
+ this.emit("FILE_RECEIVED", {
3073
+ filePath: finalPath,
3074
+ associationId: assocId,
3075
+ associationDir: assocDir,
3076
+ callingAE: data.callingAE,
3077
+ calledAE: data.calledAE,
3078
+ source: data.source,
3079
+ instance: openResult.value
3080
+ });
3081
+ }
3082
+ /** Returns worker to idle pool on association complete, emits summary. */
3083
+ wireAssociationComplete(worker) {
3084
+ worker.dcmrecv.onAssociationComplete((data) => {
3085
+ const assocId = worker.associationId ?? data.associationId;
3086
+ const assocDir = worker.associationDir ?? "";
3087
+ const files = [...worker.files];
3088
+ const endAt = Date.now();
3089
+ const startAt = worker.startAt ?? endAt;
3090
+ const totalBytes = sumArray(worker.fileSizes);
3091
+ const elapsedMs = endAt - startAt;
3092
+ const bytesPerSecond = elapsedMs > 0 ? Math.round(totalBytes / elapsedMs * 1e3) : 0;
3093
+ this.emit("ASSOCIATION_COMPLETE", {
3094
+ associationId: assocId,
3095
+ associationDir: assocDir,
3096
+ callingAE: data.callingAE,
3097
+ calledAE: data.calledAE,
3098
+ source: data.source,
3099
+ files,
3100
+ durationMs: data.durationMs,
3101
+ endReason: data.endReason,
3102
+ totalBytes,
3103
+ bytesPerSecond,
3104
+ startAt,
3105
+ endAt
3106
+ });
3107
+ worker.state = "idle";
3108
+ worker.associationId = void 0;
3109
+ worker.associationDir = void 0;
3110
+ worker.files = [];
3111
+ worker.fileSizes = [];
3112
+ worker.startAt = void 0;
3113
+ worker.remoteSocket = void 0;
3114
+ worker.workerSocket = void 0;
3115
+ void this.scaleDown();
3116
+ });
3117
+ }
3118
+ // -----------------------------------------------------------------------
3119
+ // Abort signal
3120
+ // -----------------------------------------------------------------------
3121
+ /** Wires an AbortSignal to stop the receiver. */
3122
+ wireAbortSignal(signal) {
3123
+ if (signal.aborted) {
3124
+ void this.stop();
3125
+ return;
3126
+ }
3127
+ this.abortHandler = () => {
3128
+ void this.stop();
3129
+ };
3130
+ signal.addEventListener("abort", this.abortHandler, { once: true });
3131
+ }
3132
+ };
3133
+ async function ensureDirectory(dirPath) {
3134
+ try {
3135
+ await fs__namespace.mkdir(dirPath, { recursive: true });
3136
+ return ok(void 0);
3137
+ } catch (e) {
3138
+ const msg = e instanceof Error ? e.message : String(e);
3139
+ return err(new Error(`Failed to create directory ${dirPath}: ${msg}`));
3140
+ }
3141
+ }
3142
+ async function moveFile(src, dest) {
3143
+ try {
3144
+ await fs__namespace.rename(src, dest);
3145
+ return ok(void 0);
3146
+ } catch {
3147
+ try {
3148
+ await fs__namespace.copyFile(src, dest);
3149
+ await fs__namespace.unlink(src);
3150
+ return ok(void 0);
3151
+ } catch (e) {
3152
+ const msg = e instanceof Error ? e.message : String(e);
3153
+ return err(new Error(`Failed to move file ${src} \u2192 ${dest}: ${msg}`));
3154
+ }
3155
+ }
3156
+ }
3157
+ async function statFileSafe(filePath) {
3158
+ try {
3159
+ const stat3 = await fs__namespace.stat(filePath);
3160
+ return stat3.size;
3161
+ } catch {
3162
+ return 0;
3163
+ }
3164
+ }
3165
+ function sumArray(arr) {
3166
+ let total = 0;
3167
+ for (let i = 0; i < arr.length; i++) {
3168
+ total += arr[i] ?? 0;
3169
+ }
3170
+ return total;
3171
+ }
3172
+ async function removeDirSafe(dirPath) {
3173
+ try {
3174
+ await fs__namespace.rm(dirPath, { recursive: true, force: true });
3175
+ } catch {
3176
+ }
3177
+ }
3178
+ function delay(ms) {
3179
+ return new Promise((resolve) => {
3180
+ setTimeout(resolve, ms);
3181
+ });
3182
+ }
3183
+
3184
+ // src/events/dcmprscp.ts
3185
+ var DcmprscpEvent = {
3186
+ DATABASE_READY: "DATABASE_READY",
3187
+ ASSOCIATION_RECEIVED: "ASSOCIATION_RECEIVED",
3188
+ ASSOCIATION_ACKNOWLEDGED: "ASSOCIATION_ACKNOWLEDGED",
3189
+ ASSOCIATION_RELEASE: "ASSOCIATION_RELEASE",
3190
+ ASSOCIATION_ABORTED: "ASSOCIATION_ABORTED",
3191
+ CANNOT_START_LISTENER: "CANNOT_START_LISTENER",
3192
+ CONFIG_ERROR: "CONFIG_ERROR"
3193
+ };
3194
+ var DCMPRSCP_PATTERNS = [
3195
+ {
3196
+ event: DcmprscpEvent.DATABASE_READY,
3197
+ pattern: /Using database in directory\s+'([^']+)'/,
3198
+ processor: (match) => ({
3199
+ directory: match[1] ?? ""
3200
+ })
3201
+ },
3202
+ {
3203
+ event: DcmprscpEvent.ASSOCIATION_RECEIVED,
3204
+ pattern: /Association Received\s{0,20}\(([^)]+)\)/,
3205
+ processor: (match) => ({
3206
+ peerInfo: (match[1] ?? "").trim()
3207
+ })
3208
+ },
3209
+ {
3210
+ event: DcmprscpEvent.ASSOCIATION_ACKNOWLEDGED,
3211
+ pattern: /Association Acknowledged \(Max Send PDV:\s*(\d+)\)/,
3212
+ processor: (match) => ({
3213
+ maxSendPDV: Number(match[1])
3214
+ })
3215
+ },
3216
+ {
3217
+ event: DcmprscpEvent.ASSOCIATION_RELEASE,
3218
+ pattern: /Association Release/i,
3219
+ processor: () => void 0
3220
+ },
3221
+ {
3222
+ event: DcmprscpEvent.ASSOCIATION_ABORTED,
3223
+ pattern: /Association Abort/i,
3224
+ processor: () => void 0
3225
+ },
3226
+ {
3227
+ event: DcmprscpEvent.CANNOT_START_LISTENER,
3228
+ pattern: /cannot initialise network|cannot listen/i,
3229
+ processor: (match) => ({
3230
+ message: match[0] ?? ""
3231
+ })
3232
+ },
3233
+ {
3234
+ event: DcmprscpEvent.CONFIG_ERROR,
3235
+ pattern: /can't open configuration file|no (?:default )?print scp/i,
3236
+ processor: (match) => ({
3237
+ message: match[0] ?? ""
3238
+ })
3239
+ }
3240
+ ];
3241
+ var DCMPRSCP_FATAL_EVENTS = /* @__PURE__ */ new Set([DcmprscpEvent.CANNOT_START_LISTENER, DcmprscpEvent.CONFIG_ERROR]);
3242
+
3243
+ // src/servers/DcmprsCP.ts
3244
+ var DcmprsCPOptionsSchema = zod.z.object({
3245
+ configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }),
3246
+ printer: zod.z.string().min(1).optional(),
3247
+ dump: zod.z.boolean().optional(),
3248
+ logLevel: zod.z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).optional(),
3249
+ logConfig: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in logConfig" }).optional(),
3250
+ startTimeoutMs: zod.z.number().int().positive().optional(),
3251
+ drainTimeoutMs: zod.z.number().int().positive().optional(),
3252
+ signal: zod.z.instanceof(AbortSignal).optional()
3253
+ }).strict();
3254
+ function buildArgs4(options) {
3255
+ const args = ["--verbose", "--config", options.configFile];
3256
+ if (options.printer !== void 0) {
3257
+ args.push("--printer", options.printer);
3258
+ }
3259
+ if (options.dump === true) {
3260
+ args.push("--dump");
3261
+ }
3262
+ if (options.logLevel !== void 0) {
3263
+ args.push("--log-level", options.logLevel);
3264
+ }
3265
+ if (options.logConfig !== void 0) {
3266
+ args.push("--log-config", options.logConfig);
3267
+ }
3268
+ return args;
3269
+ }
3270
+ var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
3271
+ constructor(config, parser2, signal) {
3272
+ super(config);
3273
+ __publicField(this, "parser");
3274
+ __publicField(this, "abortSignal");
3275
+ __publicField(this, "abortHandler");
3276
+ this.parser = parser2;
3277
+ this.wireParser();
3278
+ if (signal !== void 0) {
3279
+ this.wireAbortSignal(signal);
3280
+ }
3281
+ }
3282
+ /**
3283
+ * Registers a typed listener for a dcmprscp-specific event.
3284
+ *
3285
+ * @param event - The event name from DcmprsCPEventMap
3286
+ * @param listener - Callback receiving typed event data
3287
+ * @returns this for chaining
3288
+ */
3289
+ /** Disposes the server and its parser, preventing listener leaks. */
3290
+ [Symbol.dispose]() {
3291
+ if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
3292
+ this.abortSignal.removeEventListener("abort", this.abortHandler);
3293
+ }
3294
+ this.parser[Symbol.dispose]();
3295
+ super[Symbol.dispose]();
3296
+ }
3297
+ onEvent(event, listener) {
3298
+ return this.on(event, listener);
3299
+ }
3300
+ /**
3301
+ * Registers a listener for when the database is ready.
3302
+ *
3303
+ * @param listener - Callback receiving database ready data
3304
+ * @returns this for chaining
3305
+ */
3306
+ onDatabaseReady(listener) {
3307
+ return this.onEvent("DATABASE_READY", listener);
3308
+ }
3309
+ /**
3310
+ * Registers a listener for incoming associations.
3311
+ *
3312
+ * @param listener - Callback receiving association data
3313
+ * @returns this for chaining
3314
+ */
3315
+ onAssociationReceived(listener) {
3316
+ return this.onEvent("ASSOCIATION_RECEIVED", listener);
3317
+ }
3318
+ /**
3319
+ * Creates a new DcmprsCP server instance.
3320
+ *
3321
+ * @param options - Configuration options for the dcmprscp server
3322
+ * @returns A Result containing the server instance or a validation/resolution error
3323
+ */
3324
+ static create(options) {
3325
+ const validation = DcmprsCPOptionsSchema.safeParse(options);
3326
+ if (!validation.success) {
3327
+ return err(createValidationError("dcmprscp", validation.error));
3328
+ }
3329
+ const binaryResult = resolveBinary("dcmprscp");
3330
+ if (!binaryResult.ok) {
3331
+ return err(binaryResult.error);
3332
+ }
3333
+ const args = buildArgs4(options);
3334
+ const parser2 = new LineParser();
1155
3335
  for (const pattern of DCMPRSCP_PATTERNS) {
1156
- const addResult = parser.addPattern(pattern);
3336
+ const addResult = parser2.addPattern(pattern);
1157
3337
  if (!addResult.ok) {
1158
3338
  return err(addResult.error);
1159
3339
  }
@@ -1165,7 +3345,7 @@ var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
1165
3345
  drainTimeoutMs: options.drainTimeoutMs,
1166
3346
  isStartedPredicate: (line) => /Using database in directory/i.test(line)
1167
3347
  };
1168
- return ok(new _DcmprsCP(config, parser, options.signal));
3348
+ return ok(new _DcmprsCP(config, parser2, options.signal));
1169
3349
  }
1170
3350
  /** Wires the line parser to the process output. */
1171
3351
  wireParser() {
@@ -1177,7 +3357,7 @@ var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
1177
3357
  this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
1178
3358
  void this.stop();
1179
3359
  }
1180
- this.emit(event, ...[data]);
3360
+ this.emit(event, data);
1181
3361
  });
1182
3362
  }
1183
3363
  /** Wires an AbortSignal to stop the server. */
@@ -1302,7 +3482,7 @@ var DcmpsrcvOptionsSchema = zod.z.object({
1302
3482
  drainTimeoutMs: zod.z.number().int().positive().optional(),
1303
3483
  signal: zod.z.instanceof(AbortSignal).optional()
1304
3484
  }).strict();
1305
- function buildArgs4(options) {
3485
+ function buildArgs5(options) {
1306
3486
  const args = ["--verbose"];
1307
3487
  if (options.logLevel !== void 0) {
1308
3488
  args.push("--log-level", options.logLevel);
@@ -1317,12 +3497,12 @@ function buildArgs4(options) {
1317
3497
  return args;
1318
3498
  }
1319
3499
  var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
1320
- constructor(config, parser, signal) {
3500
+ constructor(config, parser2, signal) {
1321
3501
  super(config);
1322
3502
  __publicField(this, "parser");
1323
3503
  __publicField(this, "abortSignal");
1324
3504
  __publicField(this, "abortHandler");
1325
- this.parser = parser;
3505
+ this.parser = parser2;
1326
3506
  this.wireParser();
1327
3507
  if (signal !== void 0) {
1328
3508
  this.wireAbortSignal(signal);
@@ -1373,16 +3553,16 @@ var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
1373
3553
  static create(options) {
1374
3554
  const validation = DcmpsrcvOptionsSchema.safeParse(options);
1375
3555
  if (!validation.success) {
1376
- return err(new Error(`dcmpsrcv: invalid options: ${validation.error.message}`));
3556
+ return err(createValidationError("dcmpsrcv", validation.error));
1377
3557
  }
1378
3558
  const binaryResult = resolveBinary("dcmpsrcv");
1379
3559
  if (!binaryResult.ok) {
1380
3560
  return err(binaryResult.error);
1381
3561
  }
1382
- const args = buildArgs4(options);
1383
- const parser = new LineParser();
3562
+ const args = buildArgs5(options);
3563
+ const parser2 = new LineParser();
1384
3564
  for (const pattern of DCMPSRCV_PATTERNS) {
1385
- const addResult = parser.addPattern(pattern);
3565
+ const addResult = parser2.addPattern(pattern);
1386
3566
  if (!addResult.ok) {
1387
3567
  return err(addResult.error);
1388
3568
  }
@@ -1394,7 +3574,7 @@ var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
1394
3574
  drainTimeoutMs: options.drainTimeoutMs,
1395
3575
  isStartedPredicate: (line) => /Receiver\s+\S+\s+on port/i.test(line)
1396
3576
  };
1397
- return ok(new _Dcmpsrcv(config, parser, options.signal));
3577
+ return ok(new _Dcmpsrcv(config, parser2, options.signal));
1398
3578
  }
1399
3579
  /** Wires the line parser to the process output. */
1400
3580
  wireParser() {
@@ -1406,7 +3586,7 @@ var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
1406
3586
  this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
1407
3587
  void this.stop();
1408
3588
  }
1409
- this.emit(event, ...[data]);
3589
+ this.emit(event, data);
1410
3590
  });
1411
3591
  }
1412
3592
  /** Wires an AbortSignal to stop the server. */
@@ -1522,7 +3702,7 @@ var DcmQRSCPOptionsSchema = zod.z.object({
1522
3702
  drainTimeoutMs: zod.z.number().int().positive().optional(),
1523
3703
  signal: zod.z.instanceof(AbortSignal).optional()
1524
3704
  }).strict();
1525
- function buildArgs5(options) {
3705
+ function buildArgs6(options) {
1526
3706
  const args = [];
1527
3707
  if (options.verbose !== false) {
1528
3708
  args.push("--verbose");
@@ -1558,24 +3738,17 @@ function buildNetworkArgs2(args, options) {
1558
3738
  }
1559
3739
  }
1560
3740
  var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
1561
- constructor(config, parser, signal) {
3741
+ constructor(config, parser2, signal) {
1562
3742
  super(config);
1563
3743
  __publicField(this, "parser");
1564
3744
  __publicField(this, "abortSignal");
1565
3745
  __publicField(this, "abortHandler");
1566
- this.parser = parser;
3746
+ this.parser = parser2;
1567
3747
  this.wireParser();
1568
3748
  if (signal !== void 0) {
1569
3749
  this.wireAbortSignal(signal);
1570
3750
  }
1571
3751
  }
1572
- /**
1573
- * Registers a typed listener for a dcmqrscp-specific event.
1574
- *
1575
- * @param event - The event name from DcmQRSCPEventMap
1576
- * @param listener - Callback receiving typed event data
1577
- * @returns this for chaining
1578
- */
1579
3752
  /** Disposes the server and its parser, preventing listener leaks. */
1580
3753
  [Symbol.dispose]() {
1581
3754
  if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
@@ -1584,6 +3757,13 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
1584
3757
  this.parser[Symbol.dispose]();
1585
3758
  super[Symbol.dispose]();
1586
3759
  }
3760
+ /**
3761
+ * Registers a typed listener for a dcmqrscp-specific event.
3762
+ *
3763
+ * @param event - The event name from DcmQRSCPEventMap
3764
+ * @param listener - Callback receiving typed event data
3765
+ * @returns this for chaining
3766
+ */
1587
3767
  onEvent(event, listener) {
1588
3768
  return this.on(event, listener);
1589
3769
  }
@@ -1614,16 +3794,16 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
1614
3794
  static create(options) {
1615
3795
  const validation = DcmQRSCPOptionsSchema.safeParse(options);
1616
3796
  if (!validation.success) {
1617
- return err(new Error(`dcmqrscp: invalid options: ${validation.error.message}`));
3797
+ return err(createValidationError("dcmqrscp", validation.error));
1618
3798
  }
1619
3799
  const binaryResult = resolveBinary("dcmqrscp");
1620
3800
  if (!binaryResult.ok) {
1621
3801
  return err(binaryResult.error);
1622
3802
  }
1623
- const args = buildArgs5(options);
1624
- const parser = new LineParser();
3803
+ const args = buildArgs6(options);
3804
+ const parser2 = new LineParser();
1625
3805
  for (const pattern of DCMQRSCP_PATTERNS) {
1626
- const addResult = parser.addPattern(pattern);
3806
+ const addResult = parser2.addPattern(pattern);
1627
3807
  if (!addResult.ok) {
1628
3808
  return err(addResult.error);
1629
3809
  }
@@ -1634,7 +3814,7 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
1634
3814
  startTimeoutMs: options.startTimeoutMs,
1635
3815
  drainTimeoutMs: options.drainTimeoutMs
1636
3816
  };
1637
- return ok(new _DcmQRSCP(config, parser, options.signal));
3817
+ return ok(new _DcmQRSCP(config, parser2, options.signal));
1638
3818
  }
1639
3819
  /** Wires the line parser to the process output. */
1640
3820
  wireParser() {
@@ -1646,7 +3826,7 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
1646
3826
  this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
1647
3827
  void this.stop();
1648
3828
  }
1649
- this.emit(event, ...[data]);
3829
+ this.emit(event, data);
1650
3830
  });
1651
3831
  }
1652
3832
  /** Wires an AbortSignal to stop the server. */
@@ -1742,7 +3922,7 @@ var WlmscpfsOptionsSchema = zod.z.object({
1742
3922
  drainTimeoutMs: zod.z.number().int().positive().optional(),
1743
3923
  signal: zod.z.instanceof(AbortSignal).optional()
1744
3924
  }).strict();
1745
- function buildArgs6(options) {
3925
+ function buildArgs7(options) {
1746
3926
  const args = [];
1747
3927
  if (options.verbose !== false) {
1748
3928
  args.push("--verbose");
@@ -1772,24 +3952,17 @@ function buildNetworkArgs3(args, options) {
1772
3952
  }
1773
3953
  }
1774
3954
  var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
1775
- constructor(config, parser, signal) {
3955
+ constructor(config, parser2, signal) {
1776
3956
  super(config);
1777
3957
  __publicField(this, "parser");
1778
3958
  __publicField(this, "abortSignal");
1779
3959
  __publicField(this, "abortHandler");
1780
- this.parser = parser;
3960
+ this.parser = parser2;
1781
3961
  this.wireParser();
1782
3962
  if (signal !== void 0) {
1783
3963
  this.wireAbortSignal(signal);
1784
3964
  }
1785
3965
  }
1786
- /**
1787
- * Registers a typed listener for a wlmscpfs-specific event.
1788
- *
1789
- * @param event - The event name from WlmscpfsEventMap
1790
- * @param listener - Callback receiving typed event data
1791
- * @returns this for chaining
1792
- */
1793
3966
  /** Disposes the server and its parser, preventing listener leaks. */
1794
3967
  [Symbol.dispose]() {
1795
3968
  if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
@@ -1798,6 +3971,13 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
1798
3971
  this.parser[Symbol.dispose]();
1799
3972
  super[Symbol.dispose]();
1800
3973
  }
3974
+ /**
3975
+ * Registers a typed listener for a wlmscpfs-specific event.
3976
+ *
3977
+ * @param event - The event name from WlmscpfsEventMap
3978
+ * @param listener - Callback receiving typed event data
3979
+ * @returns this for chaining
3980
+ */
1801
3981
  onEvent(event, listener) {
1802
3982
  return this.on(event, listener);
1803
3983
  }
@@ -1828,16 +4008,16 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
1828
4008
  static create(options) {
1829
4009
  const validation = WlmscpfsOptionsSchema.safeParse(options);
1830
4010
  if (!validation.success) {
1831
- return err(new Error(`wlmscpfs: invalid options: ${validation.error.message}`));
4011
+ return err(createValidationError("wlmscpfs", validation.error));
1832
4012
  }
1833
4013
  const binaryResult = resolveBinary("wlmscpfs");
1834
4014
  if (!binaryResult.ok) {
1835
4015
  return err(binaryResult.error);
1836
4016
  }
1837
- const args = buildArgs6(options);
1838
- const parser = new LineParser();
4017
+ const args = buildArgs7(options);
4018
+ const parser2 = new LineParser();
1839
4019
  for (const pattern of WLMSCPFS_PATTERNS) {
1840
- const addResult = parser.addPattern(pattern);
4020
+ const addResult = parser2.addPattern(pattern);
1841
4021
  if (!addResult.ok) {
1842
4022
  return err(addResult.error);
1843
4023
  }
@@ -1849,7 +4029,7 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
1849
4029
  drainTimeoutMs: options.drainTimeoutMs
1850
4030
  // wlmscpfs doesn't reliably print "listening" — resolve on spawn
1851
4031
  };
1852
- return ok(new _Wlmscpfs(config, parser, options.signal));
4032
+ return ok(new _Wlmscpfs(config, parser2, options.signal));
1853
4033
  }
1854
4034
  /** Wires the line parser to the process output. */
1855
4035
  wireParser() {
@@ -1861,7 +4041,7 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
1861
4041
  this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
1862
4042
  void this.stop();
1863
4043
  }
1864
- this.emit(event, ...[data]);
4044
+ this.emit(event, data);
1865
4045
  });
1866
4046
  }
1867
4047
  /** Wires an AbortSignal to stop the server. */
@@ -1878,6 +4058,7 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
1878
4058
  }
1879
4059
  };
1880
4060
 
4061
+ exports.AssociationTracker = AssociationTracker;
1881
4062
  exports.DCMPRSCP_FATAL_EVENTS = DCMPRSCP_FATAL_EVENTS;
1882
4063
  exports.DCMPRSCP_PATTERNS = DCMPRSCP_PATTERNS;
1883
4064
  exports.DCMPSRCV_FATAL_EVENTS = DCMPSRCV_FATAL_EVENTS;
@@ -1895,6 +4076,7 @@ exports.DcmqrscpEvent = DcmqrscpEvent;
1895
4076
  exports.Dcmrecv = Dcmrecv;
1896
4077
  exports.DcmrecvEvent = DcmrecvEvent;
1897
4078
  exports.DcmtkProcess = DcmtkProcess;
4079
+ exports.DicomReceiver = DicomReceiver;
1898
4080
  exports.FilenameMode = FilenameMode;
1899
4081
  exports.LineParser = LineParser;
1900
4082
  exports.PreferredTransferSyntax = PreferredTransferSyntax;
@@ -1903,6 +4085,7 @@ exports.STORESCP_FATAL_EVENTS = STORESCP_FATAL_EVENTS;
1903
4085
  exports.STORESCP_PATTERNS = STORESCP_PATTERNS;
1904
4086
  exports.StorageMode = StorageMode;
1905
4087
  exports.StoreSCP = StoreSCP;
4088
+ exports.StoreSCPPreset = StoreSCPPreset;
1906
4089
  exports.StorescpEvent = StorescpEvent;
1907
4090
  exports.SubdirectoryMode = SubdirectoryMode;
1908
4091
  exports.WLMSCPFS_FATAL_EVENTS = WLMSCPFS_FATAL_EVENTS;