@ubercode/dcmtk 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -31184,6 +31184,7 @@ var Dcm2xmlOptionsSchema = zod.z.object({
31184
31184
  charset: zod.z.enum(["utf8", "latin1", "ascii"]).optional(),
31185
31185
  writeBinaryData: zod.z.boolean().optional(),
31186
31186
  encodeBinaryBase64: zod.z.boolean().optional(),
31187
+ charsetAssume: zod.z.string().min(1).optional(),
31187
31188
  verbosity: zod.z.enum(["verbose", "debug"]).optional()
31188
31189
  }).strict().optional();
31189
31190
  function pushEncodingArgs(args, options) {
@@ -31207,6 +31208,9 @@ function buildArgs(inputPath, options) {
31207
31208
  if (options?.namespace === true) {
31208
31209
  args.push("+Xn");
31209
31210
  }
31211
+ if (options?.charsetAssume !== void 0) {
31212
+ args.push("+Ca", options.charsetAssume);
31213
+ }
31210
31214
  pushEncodingArgs(args, options);
31211
31215
  args.push(inputPath);
31212
31216
  return args;
@@ -31456,6 +31460,7 @@ var Dcm2jsonOptionsSchema = zod.z.object({
31456
31460
  timeoutMs: zod.z.number().int().positive().optional(),
31457
31461
  signal: zod.z.instanceof(AbortSignal).optional(),
31458
31462
  directOnly: zod.z.boolean().optional(),
31463
+ charsetAssume: zod.z.string().min(1).optional(),
31459
31464
  verbosity: zod.z.enum(["verbose", "debug"]).optional()
31460
31465
  }).strict().optional();
31461
31466
  var VERBOSITY_FLAGS2 = { verbose: "-v", debug: "-d" };
@@ -31465,12 +31470,19 @@ function buildVerbosityArgs(verbosity) {
31465
31470
  }
31466
31471
  return [];
31467
31472
  }
31468
- async function tryXmlPath(inputPath, timeoutMs, signal, verbosity) {
31473
+ function buildXmlOpts(options) {
31474
+ const result = {};
31475
+ if (options?.verbosity !== void 0) result["verbosity"] = options.verbosity;
31476
+ if (options?.charsetAssume !== void 0) result["charsetAssume"] = options.charsetAssume;
31477
+ return result;
31478
+ }
31479
+ async function tryXmlPath(inputPath, timeoutMs, signal, opts) {
31469
31480
  const xmlBinary = resolveBinary("dcm2xml");
31470
31481
  if (!xmlBinary.ok) {
31471
31482
  return err(xmlBinary.error);
31472
31483
  }
31473
- const xmlArgs = [...buildVerbosityArgs(verbosity), "-nat", inputPath];
31484
+ const charsetArgs = opts?.charsetAssume !== void 0 ? ["+Ca", opts.charsetAssume] : [];
31485
+ const xmlArgs = [...buildVerbosityArgs(opts?.verbosity), ...charsetArgs, "-nat", inputPath];
31474
31486
  const xmlResult = await execCommand(xmlBinary.value, xmlArgs, { timeoutMs, signal });
31475
31487
  if (!xmlResult.ok) {
31476
31488
  return err(xmlResult.error);
@@ -31516,7 +31528,7 @@ async function dcm2json(inputPath, options) {
31516
31528
  if (options?.directOnly === true) {
31517
31529
  return tryDirectPath(inputPath, timeoutMs, signal, verbosity);
31518
31530
  }
31519
- const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal, verbosity);
31531
+ const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal, buildXmlOpts(options));
31520
31532
  if (xmlResult.ok) {
31521
31533
  return xmlResult;
31522
31534
  }
@@ -33131,6 +33143,30 @@ async function echoscu(options) {
33131
33143
  return ok({ success: true, stderr: result.value.stderr });
33132
33144
  }
33133
33145
  var VERBOSITY_FLAGS19 = { verbose: "-v", debug: "-d" };
33146
+ var DECOMPRESS_FLAGS = {
33147
+ never: "--decompress-never",
33148
+ lossless: "--decompress-lossless",
33149
+ lossy: "--decompress-lossy"
33150
+ };
33151
+ function pushSendModeArgs(args, options) {
33152
+ if (options.noHalt === true) {
33153
+ args.push("--no-halt");
33154
+ }
33155
+ if (options.noIllegalProposal === true) {
33156
+ args.push("--no-illegal-proposal");
33157
+ }
33158
+ if (options.decompress !== void 0) {
33159
+ args.push(DECOMPRESS_FLAGS[options.decompress]);
33160
+ }
33161
+ if (options.multiAssociations === true) {
33162
+ args.push("+ma");
33163
+ } else if (options.multiAssociations === false) {
33164
+ args.push("-ma");
33165
+ }
33166
+ if (options.createReportFile !== void 0) {
33167
+ args.push("--create-report-file", options.createReportFile);
33168
+ }
33169
+ }
33134
33170
  var DcmsendOptionsSchema = zod.z.object({
33135
33171
  timeoutMs: zod.z.number().int().positive().optional(),
33136
33172
  signal: zod.z.instanceof(AbortSignal).optional(),
@@ -33140,8 +33176,15 @@ var DcmsendOptionsSchema = zod.z.object({
33140
33176
  callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
33141
33177
  calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
33142
33178
  scanDirectory: zod.z.boolean().optional(),
33179
+ recurse: zod.z.boolean().optional(),
33180
+ scanPattern: zod.z.string().min(1).optional(),
33143
33181
  verbosity: zod.z.enum(["verbose", "debug"]).optional(),
33144
33182
  noUidChecks: zod.z.boolean().optional(),
33183
+ noHalt: zod.z.boolean().optional(),
33184
+ noIllegalProposal: zod.z.boolean().optional(),
33185
+ decompress: zod.z.enum(["never", "lossless", "lossy"]).optional(),
33186
+ multiAssociations: zod.z.boolean().optional(),
33187
+ createReportFile: zod.z.string().min(1).optional(),
33145
33188
  maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
33146
33189
  maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
33147
33190
  noHostnameLookup: zod.z.boolean().optional(),
@@ -33187,6 +33230,13 @@ function buildArgs33(options) {
33187
33230
  if (options.scanDirectory === true) {
33188
33231
  args.push("--scan-directories");
33189
33232
  }
33233
+ if (options.recurse === true) {
33234
+ args.push("+r");
33235
+ }
33236
+ if (options.scanPattern !== void 0) {
33237
+ args.push("--scan-pattern", options.scanPattern);
33238
+ }
33239
+ pushSendModeArgs(args, options);
33190
33240
  args.push(options.host, String(options.port));
33191
33241
  args.push(...options.files);
33192
33242
  return args;
@@ -34313,7 +34363,7 @@ var DicomInstance = class _DicomInstance {
34313
34363
  * Opens a DICOM file and creates a DicomInstance.
34314
34364
  *
34315
34365
  * @param path - Filesystem path to the DICOM file
34316
- * @param options - Timeout and abort options
34366
+ * @param options - Timeout, abort, and charset options
34317
34367
  * @returns A Result containing the DicomInstance or an error
34318
34368
  */
34319
34369
  static async open(path2, options) {
@@ -34321,7 +34371,8 @@ var DicomInstance = class _DicomInstance {
34321
34371
  if (!filePathResult.ok) return err(filePathResult.error);
34322
34372
  const jsonResult = await dcm2json(path2, {
34323
34373
  timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
34324
- signal: options?.signal
34374
+ signal: options?.signal,
34375
+ charsetAssume: options?.charsetAssume
34325
34376
  });
34326
34377
  if (!jsonResult.ok) return err(jsonResult.error);
34327
34378
  const datasetResult = DicomDataset.fromJson(jsonResult.value.data);
@@ -36877,68 +36928,13 @@ var SenderHealth = {
36877
36928
  DOWN: "down"
36878
36929
  };
36879
36930
 
36880
- // src/senders/DicomSender.ts
36881
- var DEFAULT_MAX_ASSOCIATIONS = 4;
36882
- var DEFAULT_MAX_QUEUE_LENGTH = 1e3;
36883
- var DEFAULT_MAX_RETRIES = 3;
36884
- var DEFAULT_RETRY_DELAY_MS = 1e3;
36885
- var DEFAULT_BUCKET_FLUSH_MS = 5e3;
36886
- var DEFAULT_MAX_BUCKET_SIZE = 50;
36887
- var MAX_ASSOCIATIONS_LIMIT = 64;
36931
+ // src/senders/SenderEngine.ts
36888
36932
  var DEGRADE_THRESHOLD = 3;
36889
36933
  var DOWN_THRESHOLD = 10;
36890
36934
  var RECOVERY_THRESHOLD = 3;
36891
- var DicomSenderOptionsSchema = zod.z.object({
36892
- host: zod.z.string().min(1),
36893
- port: zod.z.number().int().min(1).max(65535),
36894
- calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
36895
- callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
36896
- mode: zod.z.enum(["single", "multiple", "bucket"]).optional(),
36897
- maxAssociations: zod.z.number().int().min(1).max(MAX_ASSOCIATIONS_LIMIT).optional(),
36898
- proposedTransferSyntax: zod.z.union([zod.z.enum(PROPOSED_TS_VALUES), zod.z.array(zod.z.enum(PROPOSED_TS_VALUES)).min(1)]).optional(),
36899
- combineProposedTransferSyntaxes: zod.z.boolean().optional(),
36900
- maxQueueLength: zod.z.number().int().min(1).optional(),
36901
- timeoutMs: zod.z.number().int().positive().optional(),
36902
- maxRetries: zod.z.number().int().min(0).optional(),
36903
- retryDelayMs: zod.z.number().int().min(0).optional(),
36904
- bucketFlushMs: zod.z.number().int().positive().optional(),
36905
- maxBucketSize: zod.z.number().int().min(1).optional(),
36906
- maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
36907
- maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
36908
- associationTimeout: zod.z.number().int().positive().optional(),
36909
- acseTimeout: zod.z.number().int().positive().optional(),
36910
- dimseTimeout: zod.z.number().int().positive().optional(),
36911
- noHostnameLookup: zod.z.boolean().optional(),
36912
- verbosity: zod.z.enum(["verbose", "debug"]).optional(),
36913
- required: zod.z.boolean().optional(),
36914
- signal: zod.z.instanceof(AbortSignal).optional()
36915
- }).strict();
36916
- function resolveConfig(options) {
36917
- const mode = options.mode ?? "multiple";
36918
- const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS;
36919
- return {
36920
- mode,
36921
- configuredMaxAssociations: mode === "single" ? 1 : rawMax,
36922
- maxQueueLength: options.maxQueueLength ?? DEFAULT_MAX_QUEUE_LENGTH,
36923
- defaultTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
36924
- defaultMaxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
36925
- retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
36926
- bucketFlushMs: options.bucketFlushMs ?? DEFAULT_BUCKET_FLUSH_MS,
36927
- maxBucketSize: options.maxBucketSize ?? DEFAULT_MAX_BUCKET_SIZE
36928
- };
36929
- }
36930
- var DicomSender = class _DicomSender extends events.EventEmitter {
36931
- constructor(options) {
36932
- super();
36933
- __publicField(this, "options");
36934
- __publicField(this, "mode");
36935
- __publicField(this, "configuredMaxAssociations");
36936
- __publicField(this, "maxQueueLength");
36937
- __publicField(this, "defaultTimeoutMs");
36938
- __publicField(this, "defaultMaxRetries");
36939
- __publicField(this, "retryDelayMs");
36940
- __publicField(this, "bucketFlushMs");
36941
- __publicField(this, "maxBucketSize");
36935
+ var SenderEngine = class {
36936
+ constructor(config) {
36937
+ __publicField(this, "config");
36942
36938
  // Queue and concurrency state
36943
36939
  __publicField(this, "queue", []);
36944
36940
  __publicField(this, "activeAssociations", 0);
@@ -36951,113 +36947,36 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
36951
36947
  // Bucket state (bucket mode only)
36952
36948
  __publicField(this, "currentBucket", []);
36953
36949
  __publicField(this, "bucketTimer");
36954
- // AbortSignal
36955
- __publicField(this, "abortHandler");
36956
- this.setMaxListeners(20);
36957
- this.on("error", () => {
36958
- });
36959
- this.options = options;
36960
- const cfg = resolveConfig(options);
36961
- this.mode = cfg.mode;
36962
- this.configuredMaxAssociations = cfg.configuredMaxAssociations;
36963
- this.effectiveMaxAssociations = cfg.configuredMaxAssociations;
36964
- this.maxQueueLength = cfg.maxQueueLength;
36965
- this.defaultTimeoutMs = cfg.defaultTimeoutMs;
36966
- this.defaultMaxRetries = cfg.defaultMaxRetries;
36967
- this.retryDelayMs = cfg.retryDelayMs;
36968
- this.bucketFlushMs = cfg.bucketFlushMs;
36969
- this.maxBucketSize = cfg.maxBucketSize;
36970
- if (options.signal !== void 0) {
36971
- this.wireAbortSignal(options.signal);
36972
- }
36950
+ this.config = config;
36951
+ this.effectiveMaxAssociations = config.configuredMaxAssociations;
36973
36952
  }
36974
36953
  // -----------------------------------------------------------------------
36975
36954
  // Public API
36976
36955
  // -----------------------------------------------------------------------
36977
- /**
36978
- * Creates a new DicomSender instance.
36979
- *
36980
- * @param options - Configuration options
36981
- * @returns A Result containing the instance or a validation error
36982
- */
36983
- static create(options) {
36984
- const validation = DicomSenderOptionsSchema.safeParse(options);
36985
- if (!validation.success) {
36986
- return err(createValidationError("DicomSender", validation.error));
36987
- }
36988
- return ok(new _DicomSender(options));
36989
- }
36990
- /**
36991
- * Sends one or more DICOM files to the remote endpoint.
36992
- *
36993
- * In single/multiple mode, files are sent as one storescu call.
36994
- * In bucket mode, files are accumulated into a bucket and flushed
36995
- * when the bucket reaches maxBucketSize or the flush timer fires.
36996
- *
36997
- * The returned promise resolves when the files are actually sent
36998
- * (not just queued). Callers can await for confirmation or
36999
- * fire-and-forget with `void sender.send(files)`.
37000
- *
37001
- * @param files - One or more DICOM file paths
37002
- * @param options - Per-send overrides
37003
- * @returns A Result containing the send result or an error
37004
- */
37005
- send(files, options) {
36956
+ /** Sends files through the engine's queue/bucket system. */
36957
+ send(files, timeoutMs, maxRetries, binaryParams) {
37006
36958
  if (this.isStopped) {
37007
- return Promise.resolve(err(new Error("DicomSender: sender is stopped")));
36959
+ return Promise.resolve(err(new Error(`${this.config.senderName}: sender is stopped`)));
37008
36960
  }
37009
36961
  if (files.length === 0) {
37010
- return Promise.resolve(err(new Error("DicomSender: no files provided")));
37011
- }
37012
- return this.dispatchSend(files, this.buildSendParams(options));
37013
- }
37014
- /** Builds SendParams from per-send options, applying instance defaults. */
37015
- buildSendParams(options) {
37016
- return {
37017
- timeoutMs: options?.timeoutMs ?? this.defaultTimeoutMs,
37018
- maxRetries: options?.maxRetries ?? this.defaultMaxRetries,
37019
- calledAETitle: options?.calledAETitle,
37020
- callingAETitle: options?.callingAETitle,
37021
- required: options?.required,
37022
- proposedTransferSyntax: options?.proposedTransferSyntax,
37023
- combineProposedTransferSyntaxes: options?.combineProposedTransferSyntaxes
37024
- };
37025
- }
37026
- /** Dispatches a send to the appropriate mode handler. */
37027
- dispatchSend(files, params) {
37028
- switch (this.mode) {
37029
- case "single":
37030
- case "multiple":
37031
- return this.enqueueSend(files, params);
37032
- case "bucket":
37033
- return this.enqueueBucket(files, params);
37034
- default:
37035
- assertUnreachable(this.mode);
36962
+ return Promise.resolve(err(new Error(`${this.config.senderName}: no files provided`)));
37036
36963
  }
36964
+ return this.dispatchSend(files, timeoutMs, maxRetries, binaryParams);
37037
36965
  }
37038
- /**
37039
- * Flushes the current bucket immediately (bucket mode only).
37040
- * In single/multiple mode this is a no-op.
37041
- */
36966
+ /** Flushes the current bucket immediately (bucket mode only). */
37042
36967
  flush() {
37043
- if (this.mode !== "bucket") return;
36968
+ if (this.config.mode !== "bucket") return;
37044
36969
  if (this.currentBucket.length === 0) return;
37045
36970
  this.clearBucketTimer();
37046
36971
  this.flushBucketInternal("timer");
37047
36972
  }
37048
- /**
37049
- * Gracefully stops the sender. Rejects all queued items and
37050
- * waits for active associations to complete.
37051
- */
36973
+ /** Gracefully stops the engine. Rejects all queued items and waits for active associations. */
37052
36974
  async stop() {
37053
36975
  if (this.isStopped) return;
37054
36976
  this.isStopped = true;
37055
- if (this.options.signal !== void 0 && this.abortHandler !== void 0) {
37056
- this.options.signal.removeEventListener("abort", this.abortHandler);
37057
- }
37058
36977
  this.clearBucketTimer();
37059
- this.rejectBucket("DicomSender: sender stopped");
37060
- this.rejectQueue("DicomSender: sender stopped");
36978
+ this.rejectBucket(`${this.config.senderName}: sender stopped`);
36979
+ this.rejectQueue(`${this.config.senderName}: sender stopped`);
37061
36980
  await this.waitForActive();
37062
36981
  }
37063
36982
  /** Current sender status. */
@@ -37072,77 +36991,37 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37072
36991
  stopped: this.isStopped
37073
36992
  };
37074
36993
  }
36994
+ /** Whether the engine has been stopped. */
36995
+ get stopped() {
36996
+ return this.isStopped;
36997
+ }
37075
36998
  // -----------------------------------------------------------------------
37076
- // Typed event listener convenience methods
36999
+ // Dispatch
37077
37000
  // -----------------------------------------------------------------------
37078
- /**
37079
- * Registers a typed listener for a DicomSender-specific event.
37080
- *
37081
- * @param event - The event name from DicomSenderEventMap
37082
- * @param listener - Callback receiving typed event data
37083
- * @returns this for chaining
37084
- */
37085
- onEvent(event, listener) {
37086
- return this.on(event, listener);
37087
- }
37088
- /**
37089
- * Registers a listener for successful sends.
37090
- *
37091
- * @param listener - Callback receiving send complete data
37092
- * @returns this for chaining
37093
- */
37094
- onSendComplete(listener) {
37095
- return this.on("SEND_COMPLETE", listener);
37096
- }
37097
- /**
37098
- * Registers a listener for failed sends.
37099
- *
37100
- * @param listener - Callback receiving send failed data
37101
- * @returns this for chaining
37102
- */
37103
- onSendFailed(listener) {
37104
- return this.on("SEND_FAILED", listener);
37105
- }
37106
- /**
37107
- * Registers a listener for health state changes.
37108
- *
37109
- * @param listener - Callback receiving health change data
37110
- * @returns this for chaining
37111
- */
37112
- onHealthChanged(listener) {
37113
- return this.on("HEALTH_CHANGED", listener);
37114
- }
37115
- /**
37116
- * Registers a listener for bucket flushes (bucket mode only).
37117
- *
37118
- * @param listener - Callback receiving bucket flush data
37119
- * @returns this for chaining
37120
- */
37121
- onBucketFlushed(listener) {
37122
- return this.on("BUCKET_FLUSHED", listener);
37001
+ /** Dispatches a send to the appropriate mode handler. */
37002
+ dispatchSend(files, timeoutMs, maxRetries, binaryParams) {
37003
+ switch (this.config.mode) {
37004
+ case "single":
37005
+ case "multiple":
37006
+ return this.enqueueSend(files, timeoutMs, maxRetries, binaryParams);
37007
+ case "bucket":
37008
+ return this.enqueueBucket(files, timeoutMs, maxRetries, binaryParams);
37009
+ default:
37010
+ assertUnreachable(this.config.mode);
37011
+ }
37123
37012
  }
37124
37013
  // -----------------------------------------------------------------------
37125
37014
  // Single/Multiple mode: queue-based dispatch
37126
37015
  // -----------------------------------------------------------------------
37127
37016
  /** Enqueues a send and dispatches immediately if capacity allows. */
37128
- enqueueSend(files, params) {
37017
+ enqueueSend(files, timeoutMs, maxRetries, binaryParams) {
37129
37018
  return new Promise((resolve) => {
37130
37019
  const totalQueued = this.queue.length + this.currentBucket.length;
37131
- if (totalQueued >= this.maxQueueLength) {
37132
- resolve(err(new Error("DicomSender: queue full")));
37020
+ if (totalQueued >= this.config.maxQueueLength) {
37021
+ resolve(err(new Error(`${this.config.senderName}: queue full`)));
37133
37022
  return;
37134
37023
  }
37135
- const entry = {
37136
- files,
37137
- timeoutMs: params.timeoutMs,
37138
- maxRetries: params.maxRetries,
37139
- calledAETitle: params.calledAETitle,
37140
- callingAETitle: params.callingAETitle,
37141
- required: params.required,
37142
- proposedTransferSyntax: params.proposedTransferSyntax,
37143
- combineProposedTransferSyntaxes: params.combineProposedTransferSyntaxes,
37144
- resolve
37145
- };
37024
+ const entry = { files, timeoutMs, maxRetries, binaryParams, resolve };
37146
37025
  if (this.activeAssociations < this.effectiveMaxAssociations) {
37147
37026
  void this.executeEntry(entry);
37148
37027
  } else {
@@ -37162,27 +37041,16 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37162
37041
  // Bucket mode: accumulate-then-flush
37163
37042
  // -----------------------------------------------------------------------
37164
37043
  /** Adds files to the current bucket and triggers flush if full. */
37165
- enqueueBucket(files, params) {
37044
+ enqueueBucket(files, timeoutMs, maxRetries, binaryParams) {
37166
37045
  return new Promise((resolve) => {
37167
37046
  const totalQueued = this.queue.length + this.currentBucket.length;
37168
- if (totalQueued >= this.maxQueueLength) {
37169
- resolve(err(new Error("DicomSender: queue full")));
37047
+ if (totalQueued >= this.config.maxQueueLength) {
37048
+ resolve(err(new Error(`${this.config.senderName}: queue full`)));
37170
37049
  return;
37171
37050
  }
37172
- const { timeoutMs, maxRetries, calledAETitle, callingAETitle, required, proposedTransferSyntax, combineProposedTransferSyntaxes } = params;
37173
- this.currentBucket.push({
37174
- files,
37175
- resolve,
37176
- timeoutMs,
37177
- maxRetries,
37178
- calledAETitle,
37179
- callingAETitle,
37180
- required,
37181
- proposedTransferSyntax,
37182
- combineProposedTransferSyntaxes
37183
- });
37051
+ this.currentBucket.push({ files, resolve, timeoutMs, maxRetries, binaryParams });
37184
37052
  const totalFiles = this.countBucketFiles();
37185
- if (totalFiles >= this.maxBucketSize) {
37053
+ if (totalFiles >= this.config.maxBucketSize) {
37186
37054
  this.clearBucketTimer();
37187
37055
  void this.flushBucketInternal("maxSize");
37188
37056
  } else {
@@ -37214,11 +37082,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37214
37082
  files: allFiles,
37215
37083
  timeoutMs,
37216
37084
  maxRetries,
37217
- calledAETitle: entries[0]?.calledAETitle,
37218
- callingAETitle: entries[0]?.callingAETitle,
37219
- required: entries[0]?.required,
37220
- proposedTransferSyntax: entries[0]?.proposedTransferSyntax,
37221
- combineProposedTransferSyntaxes: entries[0]?.combineProposedTransferSyntaxes,
37085
+ binaryParams: entries[0].binaryParams,
37222
37086
  resolve: (result) => {
37223
37087
  for (let i = 0; i < entries.length; i++) {
37224
37088
  entries[i].resolve(result);
@@ -37232,7 +37096,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37232
37096
  const entries = [...this.currentBucket];
37233
37097
  this.currentBucket = [];
37234
37098
  const bucketEntry = this.mergeBucketEntries(entries);
37235
- this.emit("BUCKET_FLUSHED", { fileCount: bucketEntry.files.length, reason });
37099
+ this.config.emitters.emitBucketFlushed({ fileCount: bucketEntry.files.length, reason });
37236
37100
  if (this.activeAssociations < this.effectiveMaxAssociations) {
37237
37101
  void this.executeEntry(bucketEntry);
37238
37102
  } else {
@@ -37245,7 +37109,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37245
37109
  this.bucketTimer = setTimeout(() => {
37246
37110
  this.bucketTimer = void 0;
37247
37111
  void this.flushBucketInternal("timer");
37248
- }, this.bucketFlushMs);
37112
+ }, this.config.bucketFlushMs);
37249
37113
  }
37250
37114
  /** Clears the bucket flush timer. */
37251
37115
  clearBucketTimer() {
@@ -37257,7 +37121,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37257
37121
  // -----------------------------------------------------------------------
37258
37122
  // Core send execution with retry
37259
37123
  // -----------------------------------------------------------------------
37260
- /** Executes a single queue entry: calls storescu with retry. */
37124
+ /** Executes a single queue entry: calls the binary with retry. */
37261
37125
  async executeEntry(entry) {
37262
37126
  this.activeAssociations++;
37263
37127
  const startMs = Date.now();
@@ -37266,7 +37130,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37266
37130
  if (failure === void 0) return;
37267
37131
  this.activeAssociations--;
37268
37132
  this.recordFailure();
37269
- this.emit("SEND_FAILED", {
37133
+ this.config.emitters.emitSendFailed({
37270
37134
  files: entry.files,
37271
37135
  error: failure.error,
37272
37136
  attempts: maxAttempts,
@@ -37276,49 +37140,27 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37276
37140
  entry.resolve(err(failure.error));
37277
37141
  this.drainQueue();
37278
37142
  }
37279
- /** Attempts storescu up to maxAttempts times. Returns undefined on success, or failure info. */
37143
+ /** Attempts the binary call up to maxAttempts times. Returns undefined on success. */
37280
37144
  async attemptSend(entry, maxAttempts, startMs) {
37281
37145
  let lastError;
37282
37146
  const lastOutput = { stdout: "", stderr: "" };
37283
37147
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
37284
37148
  if (this.isStopped) {
37285
37149
  this.activeAssociations--;
37286
- entry.resolve(err(new Error("DicomSender: sender stopped")));
37150
+ entry.resolve(err(new Error(`${this.config.senderName}: sender stopped`)));
37287
37151
  return void 0;
37288
37152
  }
37289
- const result = await this.callStorescu(entry);
37153
+ const result = await this.config.executor(entry.files, entry.timeoutMs, entry.binaryParams, this.config.signal);
37290
37154
  if (result.ok) {
37291
37155
  this.handleSendSuccess(entry, startMs, result.value);
37292
37156
  return void 0;
37293
37157
  }
37294
37158
  lastError = result.error;
37295
37159
  if (attempt < maxAttempts - 1) {
37296
- await delay2(this.retryDelayMs * (attempt + 1));
37160
+ await delay2(this.config.retryDelayMs * (attempt + 1));
37297
37161
  }
37298
37162
  }
37299
- return { error: lastError ?? new Error("DicomSender: send failed"), output: lastOutput };
37300
- }
37301
- /** Calls storescu with the configured options. */
37302
- callStorescu(entry) {
37303
- return storescu({
37304
- host: this.options.host,
37305
- port: this.options.port,
37306
- files: [...entry.files],
37307
- calledAETitle: entry.calledAETitle ?? this.options.calledAETitle,
37308
- callingAETitle: entry.callingAETitle ?? this.options.callingAETitle,
37309
- proposedTransferSyntax: entry.proposedTransferSyntax ?? this.options.proposedTransferSyntax,
37310
- combineProposedTransferSyntaxes: entry.combineProposedTransferSyntaxes ?? this.options.combineProposedTransferSyntaxes,
37311
- maxPduReceive: this.options.maxPduReceive,
37312
- maxPduSend: this.options.maxPduSend,
37313
- associationTimeout: this.options.associationTimeout,
37314
- acseTimeout: this.options.acseTimeout,
37315
- dimseTimeout: this.options.dimseTimeout,
37316
- noHostnameLookup: this.options.noHostnameLookup,
37317
- verbosity: this.options.verbosity,
37318
- required: entry.required ?? this.options.required,
37319
- timeoutMs: entry.timeoutMs,
37320
- signal: this.options.signal
37321
- });
37163
+ return { error: lastError ?? new Error(`${this.config.senderName}: send failed`), output: lastOutput };
37322
37164
  }
37323
37165
  /** Handles a successful send: updates state, emits event, resolves promise. */
37324
37166
  handleSendSuccess(entry, startMs, output) {
@@ -37326,7 +37168,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37326
37168
  const durationMs = Date.now() - startMs;
37327
37169
  this.recordSuccess();
37328
37170
  const data = { files: entry.files, fileCount: entry.files.length, durationMs, stdout: output.stdout, stderr: output.stderr };
37329
- this.emit("SEND_COMPLETE", data);
37171
+ this.config.emitters.emitSendComplete(data);
37330
37172
  entry.resolve(ok(data));
37331
37173
  this.drainQueue();
37332
37174
  }
@@ -37344,8 +37186,8 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37344
37186
  if (this.health === SenderHealth.DOWN) {
37345
37187
  this.health = SenderHealth.DEGRADED;
37346
37188
  } else {
37347
- this.effectiveMaxAssociations = Math.min(this.effectiveMaxAssociations * 2, this.configuredMaxAssociations);
37348
- if (this.effectiveMaxAssociations >= this.configuredMaxAssociations) {
37189
+ this.effectiveMaxAssociations = Math.min(this.effectiveMaxAssociations * 2, this.config.configuredMaxAssociations);
37190
+ if (this.effectiveMaxAssociations >= this.config.configuredMaxAssociations) {
37349
37191
  this.health = SenderHealth.HEALTHY;
37350
37192
  }
37351
37193
  }
@@ -37365,22 +37207,26 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37365
37207
  this.emitHealthChanged(previousHealth);
37366
37208
  }
37367
37209
  } else if (this.consecutiveFailures >= DEGRADE_THRESHOLD && this.consecutiveFailures % DEGRADE_THRESHOLD === 0) {
37368
- if (this.health === SenderHealth.HEALTHY) {
37369
- this.health = SenderHealth.DEGRADED;
37370
- this.effectiveMaxAssociations = Math.max(1, Math.floor(this.configuredMaxAssociations / 2));
37210
+ this.degradeHealth(previousHealth);
37211
+ }
37212
+ }
37213
+ /** Handles HEALTHY→DEGRADED or DEGRADED→DEGRADED transitions. */
37214
+ degradeHealth(previousHealth) {
37215
+ if (this.health === SenderHealth.HEALTHY) {
37216
+ this.health = SenderHealth.DEGRADED;
37217
+ this.effectiveMaxAssociations = Math.max(1, Math.floor(this.config.configuredMaxAssociations / 2));
37218
+ this.emitHealthChanged(previousHealth);
37219
+ } else if (this.health === SenderHealth.DEGRADED) {
37220
+ const newMax = Math.max(1, Math.floor(this.effectiveMaxAssociations / 2));
37221
+ if (newMax !== this.effectiveMaxAssociations) {
37222
+ this.effectiveMaxAssociations = newMax;
37371
37223
  this.emitHealthChanged(previousHealth);
37372
- } else if (this.health === SenderHealth.DEGRADED) {
37373
- const newMax = Math.max(1, Math.floor(this.effectiveMaxAssociations / 2));
37374
- if (newMax !== this.effectiveMaxAssociations) {
37375
- this.effectiveMaxAssociations = newMax;
37376
- this.emitHealthChanged(previousHealth);
37377
- }
37378
37224
  }
37379
37225
  }
37380
37226
  }
37381
- /** Emits a HEALTH_CHANGED event. */
37227
+ /** Emits a HEALTH_CHANGED event via the wrapper's emitter. */
37382
37228
  emitHealthChanged(previousHealth) {
37383
- this.emit("HEALTH_CHANGED", {
37229
+ this.config.emitters.emitHealthChanged({
37384
37230
  previousHealth,
37385
37231
  newHealth: this.health,
37386
37232
  effectiveMaxAssociations: this.effectiveMaxAssociations,
@@ -37420,20 +37266,6 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37420
37266
  check();
37421
37267
  });
37422
37268
  }
37423
- // -----------------------------------------------------------------------
37424
- // Abort signal
37425
- // -----------------------------------------------------------------------
37426
- /** Wires an AbortSignal to stop the sender. */
37427
- wireAbortSignal(signal) {
37428
- if (signal.aborted) {
37429
- void this.stop();
37430
- return;
37431
- }
37432
- this.abortHandler = () => {
37433
- void this.stop();
37434
- };
37435
- signal.addEventListener("abort", this.abortHandler, { once: true });
37436
- }
37437
37269
  };
37438
37270
  function delay2(ms) {
37439
37271
  return new Promise((resolve) => {
@@ -37441,6 +37273,492 @@ function delay2(ms) {
37441
37273
  });
37442
37274
  }
37443
37275
 
37276
+ // src/senders/DicomSender.ts
37277
+ var DEFAULT_MAX_ASSOCIATIONS = 4;
37278
+ var DEFAULT_MAX_QUEUE_LENGTH = 1e3;
37279
+ var DEFAULT_MAX_RETRIES = 3;
37280
+ var DEFAULT_RETRY_DELAY_MS = 1e3;
37281
+ var DEFAULT_BUCKET_FLUSH_MS = 5e3;
37282
+ var DEFAULT_MAX_BUCKET_SIZE = 50;
37283
+ var MAX_ASSOCIATIONS_LIMIT = 64;
37284
+ var DicomSenderOptionsSchema = zod.z.object({
37285
+ host: zod.z.string().min(1),
37286
+ port: zod.z.number().int().min(1).max(65535),
37287
+ calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37288
+ callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37289
+ mode: zod.z.enum(["single", "multiple", "bucket"]).optional(),
37290
+ maxAssociations: zod.z.number().int().min(1).max(MAX_ASSOCIATIONS_LIMIT).optional(),
37291
+ proposedTransferSyntax: zod.z.union([zod.z.enum(PROPOSED_TS_VALUES), zod.z.array(zod.z.enum(PROPOSED_TS_VALUES)).min(1)]).optional(),
37292
+ combineProposedTransferSyntaxes: zod.z.boolean().optional(),
37293
+ maxQueueLength: zod.z.number().int().min(1).optional(),
37294
+ timeoutMs: zod.z.number().int().positive().optional(),
37295
+ maxRetries: zod.z.number().int().min(0).optional(),
37296
+ retryDelayMs: zod.z.number().int().min(0).optional(),
37297
+ bucketFlushMs: zod.z.number().int().positive().optional(),
37298
+ maxBucketSize: zod.z.number().int().min(1).optional(),
37299
+ maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
37300
+ maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
37301
+ associationTimeout: zod.z.number().int().positive().optional(),
37302
+ acseTimeout: zod.z.number().int().positive().optional(),
37303
+ dimseTimeout: zod.z.number().int().positive().optional(),
37304
+ noHostnameLookup: zod.z.boolean().optional(),
37305
+ verbosity: zod.z.enum(["verbose", "debug"]).optional(),
37306
+ required: zod.z.boolean().optional(),
37307
+ signal: zod.z.instanceof(AbortSignal).optional()
37308
+ }).strict();
37309
+ function createStorescuExecutor(options) {
37310
+ return async (files, timeoutMs, params, signal) => {
37311
+ const result = await storescu({
37312
+ host: options.host,
37313
+ port: options.port,
37314
+ files: [...files],
37315
+ calledAETitle: params.calledAETitle ?? options.calledAETitle,
37316
+ callingAETitle: params.callingAETitle ?? options.callingAETitle,
37317
+ proposedTransferSyntax: params.proposedTransferSyntax ?? options.proposedTransferSyntax,
37318
+ combineProposedTransferSyntaxes: params.combineProposedTransferSyntaxes ?? options.combineProposedTransferSyntaxes,
37319
+ maxPduReceive: options.maxPduReceive,
37320
+ maxPduSend: options.maxPduSend,
37321
+ associationTimeout: options.associationTimeout,
37322
+ acseTimeout: options.acseTimeout,
37323
+ dimseTimeout: options.dimseTimeout,
37324
+ noHostnameLookup: options.noHostnameLookup,
37325
+ verbosity: options.verbosity,
37326
+ required: params.required ?? options.required,
37327
+ timeoutMs,
37328
+ signal
37329
+ });
37330
+ if (!result.ok) return err(result.error);
37331
+ return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37332
+ };
37333
+ }
37334
+ function resolveConfig(options) {
37335
+ const mode = options.mode ?? "multiple";
37336
+ const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS;
37337
+ return {
37338
+ mode,
37339
+ configuredMaxAssociations: mode === "single" ? 1 : rawMax,
37340
+ maxQueueLength: options.maxQueueLength ?? DEFAULT_MAX_QUEUE_LENGTH,
37341
+ defaultTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
37342
+ defaultMaxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
37343
+ retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
37344
+ bucketFlushMs: options.bucketFlushMs ?? DEFAULT_BUCKET_FLUSH_MS,
37345
+ maxBucketSize: options.maxBucketSize ?? DEFAULT_MAX_BUCKET_SIZE
37346
+ };
37347
+ }
37348
+ var DicomSender = class _DicomSender extends events.EventEmitter {
37349
+ constructor(engine, cfg, signal) {
37350
+ super();
37351
+ __publicField(this, "engine");
37352
+ __publicField(this, "defaultTimeoutMs");
37353
+ __publicField(this, "defaultMaxRetries");
37354
+ __publicField(this, "signal");
37355
+ __publicField(this, "abortHandler");
37356
+ this.setMaxListeners(20);
37357
+ this.on("error", () => {
37358
+ });
37359
+ this.engine = engine;
37360
+ this.defaultTimeoutMs = cfg.defaultTimeoutMs;
37361
+ this.defaultMaxRetries = cfg.defaultMaxRetries;
37362
+ this.signal = signal;
37363
+ if (signal !== void 0) {
37364
+ this.wireAbortSignal(signal);
37365
+ }
37366
+ }
37367
+ // -----------------------------------------------------------------------
37368
+ // Public API
37369
+ // -----------------------------------------------------------------------
37370
+ /**
37371
+ * Creates a new DicomSender instance.
37372
+ *
37373
+ * @param options - Configuration options
37374
+ * @returns A Result containing the instance or a validation error
37375
+ */
37376
+ static create(options) {
37377
+ const validation = DicomSenderOptionsSchema.safeParse(options);
37378
+ if (!validation.success) {
37379
+ return err(createValidationError("DicomSender", validation.error));
37380
+ }
37381
+ const cfg = resolveConfig(options);
37382
+ const senderRef = { current: void 0 };
37383
+ const engine = new SenderEngine({
37384
+ ...cfg,
37385
+ executor: createStorescuExecutor(options),
37386
+ signal: options.signal,
37387
+ senderName: "DicomSender",
37388
+ emitters: {
37389
+ emitSendComplete: (data) => {
37390
+ senderRef.current.emit("SEND_COMPLETE", data);
37391
+ },
37392
+ emitSendFailed: (data) => {
37393
+ senderRef.current.emit("SEND_FAILED", data);
37394
+ },
37395
+ emitHealthChanged: (data) => {
37396
+ senderRef.current.emit("HEALTH_CHANGED", data);
37397
+ },
37398
+ emitBucketFlushed: (data) => {
37399
+ senderRef.current.emit("BUCKET_FLUSHED", data);
37400
+ }
37401
+ }
37402
+ });
37403
+ const sender = new _DicomSender(engine, cfg, options.signal);
37404
+ senderRef.current = sender;
37405
+ return ok(sender);
37406
+ }
37407
+ /**
37408
+ * Sends one or more DICOM files to the remote endpoint.
37409
+ *
37410
+ * In single/multiple mode, files are sent as one storescu call.
37411
+ * In bucket mode, files are accumulated into a bucket and flushed
37412
+ * when the bucket reaches maxBucketSize or the flush timer fires.
37413
+ *
37414
+ * The returned promise resolves when the files are actually sent
37415
+ * (not just queued). Callers can await for confirmation or
37416
+ * fire-and-forget with `void sender.send(files)`.
37417
+ *
37418
+ * @param files - One or more DICOM file paths
37419
+ * @param options - Per-send overrides
37420
+ * @returns A Result containing the send result or an error
37421
+ */
37422
+ send(files, options) {
37423
+ const params = buildStorescuParams(options);
37424
+ const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
37425
+ const maxRetries = options?.maxRetries ?? this.defaultMaxRetries;
37426
+ return this.engine.send(files, timeoutMs, maxRetries, params);
37427
+ }
37428
+ /**
37429
+ * Flushes the current bucket immediately (bucket mode only).
37430
+ * In single/multiple mode this is a no-op.
37431
+ */
37432
+ flush() {
37433
+ this.engine.flush();
37434
+ }
37435
+ /**
37436
+ * Gracefully stops the sender. Rejects all queued items and
37437
+ * waits for active associations to complete.
37438
+ */
37439
+ async stop() {
37440
+ if (this.signal !== void 0 && this.abortHandler !== void 0) {
37441
+ this.signal.removeEventListener("abort", this.abortHandler);
37442
+ }
37443
+ await this.engine.stop();
37444
+ }
37445
+ /** Current sender status. */
37446
+ get status() {
37447
+ return this.engine.status;
37448
+ }
37449
+ // -----------------------------------------------------------------------
37450
+ // Typed event listener convenience methods
37451
+ // -----------------------------------------------------------------------
37452
+ /**
37453
+ * Registers a typed listener for a DicomSender-specific event.
37454
+ *
37455
+ * @param event - The event name from DicomSenderEventMap
37456
+ * @param listener - Callback receiving typed event data
37457
+ * @returns this for chaining
37458
+ */
37459
+ onEvent(event, listener) {
37460
+ return this.on(event, listener);
37461
+ }
37462
+ /**
37463
+ * Registers a listener for successful sends.
37464
+ *
37465
+ * @param listener - Callback receiving send complete data
37466
+ * @returns this for chaining
37467
+ */
37468
+ onSendComplete(listener) {
37469
+ return this.on("SEND_COMPLETE", listener);
37470
+ }
37471
+ /**
37472
+ * Registers a listener for failed sends.
37473
+ *
37474
+ * @param listener - Callback receiving send failed data
37475
+ * @returns this for chaining
37476
+ */
37477
+ onSendFailed(listener) {
37478
+ return this.on("SEND_FAILED", listener);
37479
+ }
37480
+ /**
37481
+ * Registers a listener for health state changes.
37482
+ *
37483
+ * @param listener - Callback receiving health change data
37484
+ * @returns this for chaining
37485
+ */
37486
+ onHealthChanged(listener) {
37487
+ return this.on("HEALTH_CHANGED", listener);
37488
+ }
37489
+ /**
37490
+ * Registers a listener for bucket flushes (bucket mode only).
37491
+ *
37492
+ * @param listener - Callback receiving bucket flush data
37493
+ * @returns this for chaining
37494
+ */
37495
+ onBucketFlushed(listener) {
37496
+ return this.on("BUCKET_FLUSHED", listener);
37497
+ }
37498
+ // -----------------------------------------------------------------------
37499
+ // Abort signal
37500
+ // -----------------------------------------------------------------------
37501
+ /** Wires an AbortSignal to stop the sender. */
37502
+ wireAbortSignal(signal) {
37503
+ if (signal.aborted) {
37504
+ void this.stop();
37505
+ return;
37506
+ }
37507
+ this.abortHandler = () => {
37508
+ void this.stop();
37509
+ };
37510
+ signal.addEventListener("abort", this.abortHandler, { once: true });
37511
+ }
37512
+ };
37513
+ function buildStorescuParams(options) {
37514
+ return {
37515
+ calledAETitle: options?.calledAETitle,
37516
+ callingAETitle: options?.callingAETitle,
37517
+ required: options?.required,
37518
+ proposedTransferSyntax: options?.proposedTransferSyntax,
37519
+ combineProposedTransferSyntaxes: options?.combineProposedTransferSyntaxes
37520
+ };
37521
+ }
37522
+ var DEFAULT_MAX_ASSOCIATIONS2 = 4;
37523
+ var DEFAULT_MAX_QUEUE_LENGTH2 = 1e3;
37524
+ var DEFAULT_MAX_RETRIES2 = 3;
37525
+ var DEFAULT_RETRY_DELAY_MS2 = 1e3;
37526
+ var DEFAULT_BUCKET_FLUSH_MS2 = 5e3;
37527
+ var DEFAULT_MAX_BUCKET_SIZE2 = 50;
37528
+ var MAX_ASSOCIATIONS_LIMIT2 = 64;
37529
+ var DicomSendOptionsSchema = zod.z.object({
37530
+ host: zod.z.string().min(1),
37531
+ port: zod.z.number().int().min(1).max(65535),
37532
+ calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37533
+ callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37534
+ mode: zod.z.enum(["single", "multiple", "bucket"]).optional(),
37535
+ maxAssociations: zod.z.number().int().min(1).max(MAX_ASSOCIATIONS_LIMIT2).optional(),
37536
+ maxQueueLength: zod.z.number().int().min(1).optional(),
37537
+ timeoutMs: zod.z.number().int().positive().optional(),
37538
+ maxRetries: zod.z.number().int().min(0).optional(),
37539
+ retryDelayMs: zod.z.number().int().min(0).optional(),
37540
+ bucketFlushMs: zod.z.number().int().positive().optional(),
37541
+ maxBucketSize: zod.z.number().int().min(1).optional(),
37542
+ maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
37543
+ maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
37544
+ associationTimeout: zod.z.number().int().positive().optional(),
37545
+ acseTimeout: zod.z.number().int().positive().optional(),
37546
+ dimseTimeout: zod.z.number().int().positive().optional(),
37547
+ noHostnameLookup: zod.z.boolean().optional(),
37548
+ verbosity: zod.z.enum(["verbose", "debug"]).optional(),
37549
+ noHalt: zod.z.boolean().optional(),
37550
+ noIllegalProposal: zod.z.boolean().optional(),
37551
+ decompress: zod.z.enum(["never", "lossless", "lossy"]).optional(),
37552
+ multiAssociations: zod.z.boolean().optional(),
37553
+ noUidChecks: zod.z.boolean().optional(),
37554
+ signal: zod.z.instanceof(AbortSignal).optional()
37555
+ }).strict();
37556
+ function createDcmsendExecutor(options) {
37557
+ return async (files, timeoutMs, params, signal) => {
37558
+ const result = await dcmsend({
37559
+ host: options.host,
37560
+ port: options.port,
37561
+ files: [...files],
37562
+ calledAETitle: params.calledAETitle ?? options.calledAETitle,
37563
+ callingAETitle: params.callingAETitle ?? options.callingAETitle,
37564
+ maxPduReceive: options.maxPduReceive,
37565
+ maxPduSend: options.maxPduSend,
37566
+ associationTimeout: options.associationTimeout,
37567
+ acseTimeout: options.acseTimeout,
37568
+ dimseTimeout: options.dimseTimeout,
37569
+ noHostnameLookup: options.noHostnameLookup,
37570
+ verbosity: options.verbosity,
37571
+ noHalt: options.noHalt,
37572
+ noIllegalProposal: options.noIllegalProposal,
37573
+ decompress: options.decompress,
37574
+ multiAssociations: options.multiAssociations,
37575
+ noUidChecks: options.noUidChecks,
37576
+ timeoutMs,
37577
+ signal
37578
+ });
37579
+ if (!result.ok) return err(result.error);
37580
+ return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37581
+ };
37582
+ }
37583
+ function resolveConfig2(options) {
37584
+ const mode = options.mode ?? "multiple";
37585
+ const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS2;
37586
+ return {
37587
+ mode,
37588
+ configuredMaxAssociations: mode === "single" ? 1 : rawMax,
37589
+ maxQueueLength: options.maxQueueLength ?? DEFAULT_MAX_QUEUE_LENGTH2,
37590
+ defaultTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
37591
+ defaultMaxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES2,
37592
+ retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS2,
37593
+ bucketFlushMs: options.bucketFlushMs ?? DEFAULT_BUCKET_FLUSH_MS2,
37594
+ maxBucketSize: options.maxBucketSize ?? DEFAULT_MAX_BUCKET_SIZE2
37595
+ };
37596
+ }
37597
+ var DicomSend = class _DicomSend extends events.EventEmitter {
37598
+ constructor(engine, cfg, signal) {
37599
+ super();
37600
+ __publicField(this, "engine");
37601
+ __publicField(this, "defaultTimeoutMs");
37602
+ __publicField(this, "defaultMaxRetries");
37603
+ __publicField(this, "signal");
37604
+ __publicField(this, "abortHandler");
37605
+ this.setMaxListeners(20);
37606
+ this.on("error", () => {
37607
+ });
37608
+ this.engine = engine;
37609
+ this.defaultTimeoutMs = cfg.defaultTimeoutMs;
37610
+ this.defaultMaxRetries = cfg.defaultMaxRetries;
37611
+ this.signal = signal;
37612
+ if (signal !== void 0) {
37613
+ this.wireAbortSignal(signal);
37614
+ }
37615
+ }
37616
+ // -----------------------------------------------------------------------
37617
+ // Public API
37618
+ // -----------------------------------------------------------------------
37619
+ /**
37620
+ * Creates a new DicomSend instance.
37621
+ *
37622
+ * @param options - Configuration options
37623
+ * @returns A Result containing the instance or a validation error
37624
+ */
37625
+ static create(options) {
37626
+ const validation = DicomSendOptionsSchema.safeParse(options);
37627
+ if (!validation.success) {
37628
+ return err(createValidationError("DicomSend", validation.error));
37629
+ }
37630
+ const cfg = resolveConfig2(options);
37631
+ const senderRef = { current: void 0 };
37632
+ const engine = new SenderEngine({
37633
+ ...cfg,
37634
+ executor: createDcmsendExecutor(options),
37635
+ signal: options.signal,
37636
+ senderName: "DicomSend",
37637
+ emitters: {
37638
+ emitSendComplete: (data) => {
37639
+ senderRef.current.emit("SEND_COMPLETE", data);
37640
+ },
37641
+ emitSendFailed: (data) => {
37642
+ senderRef.current.emit("SEND_FAILED", data);
37643
+ },
37644
+ emitHealthChanged: (data) => {
37645
+ senderRef.current.emit("HEALTH_CHANGED", data);
37646
+ },
37647
+ emitBucketFlushed: (data) => {
37648
+ senderRef.current.emit("BUCKET_FLUSHED", data);
37649
+ }
37650
+ }
37651
+ });
37652
+ const sender = new _DicomSend(engine, cfg, options.signal);
37653
+ senderRef.current = sender;
37654
+ return ok(sender);
37655
+ }
37656
+ /**
37657
+ * Sends one or more DICOM files to the remote endpoint.
37658
+ *
37659
+ * In single/multiple mode, files are sent as one dcmsend call.
37660
+ * In bucket mode, files are accumulated into a bucket and flushed
37661
+ * when the bucket reaches maxBucketSize or the flush timer fires.
37662
+ *
37663
+ * @param files - One or more DICOM file paths
37664
+ * @param options - Per-send overrides
37665
+ * @returns A Result containing the send result or an error
37666
+ */
37667
+ send(files, options) {
37668
+ const params = {
37669
+ calledAETitle: options?.calledAETitle,
37670
+ callingAETitle: options?.callingAETitle
37671
+ };
37672
+ const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
37673
+ const maxRetries = options?.maxRetries ?? this.defaultMaxRetries;
37674
+ return this.engine.send(files, timeoutMs, maxRetries, params);
37675
+ }
37676
+ /**
37677
+ * Flushes the current bucket immediately (bucket mode only).
37678
+ * In single/multiple mode this is a no-op.
37679
+ */
37680
+ flush() {
37681
+ this.engine.flush();
37682
+ }
37683
+ /**
37684
+ * Gracefully stops the sender. Rejects all queued items and
37685
+ * waits for active associations to complete.
37686
+ */
37687
+ async stop() {
37688
+ if (this.signal !== void 0 && this.abortHandler !== void 0) {
37689
+ this.signal.removeEventListener("abort", this.abortHandler);
37690
+ }
37691
+ await this.engine.stop();
37692
+ }
37693
+ /** Current sender status. */
37694
+ get status() {
37695
+ return this.engine.status;
37696
+ }
37697
+ // -----------------------------------------------------------------------
37698
+ // Typed event listener convenience methods
37699
+ // -----------------------------------------------------------------------
37700
+ /**
37701
+ * Registers a typed listener for a DicomSend-specific event.
37702
+ *
37703
+ * @param event - The event name from DicomSendEventMap
37704
+ * @param listener - Callback receiving typed event data
37705
+ * @returns this for chaining
37706
+ */
37707
+ onEvent(event, listener) {
37708
+ return this.on(event, listener);
37709
+ }
37710
+ /**
37711
+ * Registers a listener for successful sends.
37712
+ *
37713
+ * @param listener - Callback receiving send complete data
37714
+ * @returns this for chaining
37715
+ */
37716
+ onSendComplete(listener) {
37717
+ return this.on("SEND_COMPLETE", listener);
37718
+ }
37719
+ /**
37720
+ * Registers a listener for failed sends.
37721
+ *
37722
+ * @param listener - Callback receiving send failed data
37723
+ * @returns this for chaining
37724
+ */
37725
+ onSendFailed(listener) {
37726
+ return this.on("SEND_FAILED", listener);
37727
+ }
37728
+ /**
37729
+ * Registers a listener for health state changes.
37730
+ *
37731
+ * @param listener - Callback receiving health change data
37732
+ * @returns this for chaining
37733
+ */
37734
+ onHealthChanged(listener) {
37735
+ return this.on("HEALTH_CHANGED", listener);
37736
+ }
37737
+ /**
37738
+ * Registers a listener for bucket flushes (bucket mode only).
37739
+ *
37740
+ * @param listener - Callback receiving bucket flush data
37741
+ * @returns this for chaining
37742
+ */
37743
+ onBucketFlushed(listener) {
37744
+ return this.on("BUCKET_FLUSHED", listener);
37745
+ }
37746
+ // -----------------------------------------------------------------------
37747
+ // Abort signal
37748
+ // -----------------------------------------------------------------------
37749
+ /** Wires an AbortSignal to stop the sender. */
37750
+ wireAbortSignal(signal) {
37751
+ if (signal.aborted) {
37752
+ void this.stop();
37753
+ return;
37754
+ }
37755
+ this.abortHandler = () => {
37756
+ void this.stop();
37757
+ };
37758
+ signal.addEventListener("abort", this.abortHandler, { once: true });
37759
+ }
37760
+ };
37761
+
37444
37762
  // src/pacs/types.ts
37445
37763
  var QueryLevel = {
37446
37764
  STUDY: "STUDY",
@@ -37990,7 +38308,7 @@ var DEFAULT_CONFIG = {
37990
38308
  signal: void 0,
37991
38309
  onRetry: void 0
37992
38310
  };
37993
- function resolveConfig2(opts) {
38311
+ function resolveConfig3(opts) {
37994
38312
  if (!opts) return DEFAULT_CONFIG;
37995
38313
  return {
37996
38314
  ...DEFAULT_CONFIG,
@@ -38035,7 +38353,7 @@ function shouldBreakAfterFailure(attempt, lastError, config) {
38035
38353
  return false;
38036
38354
  }
38037
38355
  async function retry(operation, options) {
38038
- const config = resolveConfig2(options);
38356
+ const config = resolveConfig3(options);
38039
38357
  let lastResult = err(new Error("No attempts executed"));
38040
38358
  for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
38041
38359
  lastResult = await operation();
@@ -38083,6 +38401,7 @@ exports.DcmtkProcess = DcmtkProcess;
38083
38401
  exports.DicomDataset = DicomDataset;
38084
38402
  exports.DicomInstance = DicomInstance;
38085
38403
  exports.DicomReceiver = DicomReceiver;
38404
+ exports.DicomSend = DicomSend;
38086
38405
  exports.DicomSender = DicomSender;
38087
38406
  exports.DicomTagPathSchema = DicomTagPathSchema;
38088
38407
  exports.DicomTagSchema = DicomTagSchema;