@ubercode/dcmtk 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -33131,6 +33131,30 @@ async function echoscu(options) {
33131
33131
  return ok({ success: true, stderr: result.value.stderr });
33132
33132
  }
33133
33133
  var VERBOSITY_FLAGS19 = { verbose: "-v", debug: "-d" };
33134
+ var DECOMPRESS_FLAGS = {
33135
+ never: "--decompress-never",
33136
+ lossless: "--decompress-lossless",
33137
+ lossy: "--decompress-lossy"
33138
+ };
33139
+ function pushSendModeArgs(args, options) {
33140
+ if (options.noHalt === true) {
33141
+ args.push("--no-halt");
33142
+ }
33143
+ if (options.noIllegalProposal === true) {
33144
+ args.push("--no-illegal-proposal");
33145
+ }
33146
+ if (options.decompress !== void 0) {
33147
+ args.push(DECOMPRESS_FLAGS[options.decompress]);
33148
+ }
33149
+ if (options.multiAssociations === true) {
33150
+ args.push("+ma");
33151
+ } else if (options.multiAssociations === false) {
33152
+ args.push("-ma");
33153
+ }
33154
+ if (options.createReportFile !== void 0) {
33155
+ args.push("--create-report-file", options.createReportFile);
33156
+ }
33157
+ }
33134
33158
  var DcmsendOptionsSchema = zod.z.object({
33135
33159
  timeoutMs: zod.z.number().int().positive().optional(),
33136
33160
  signal: zod.z.instanceof(AbortSignal).optional(),
@@ -33140,8 +33164,15 @@ var DcmsendOptionsSchema = zod.z.object({
33140
33164
  callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
33141
33165
  calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
33142
33166
  scanDirectory: zod.z.boolean().optional(),
33167
+ recurse: zod.z.boolean().optional(),
33168
+ scanPattern: zod.z.string().min(1).optional(),
33143
33169
  verbosity: zod.z.enum(["verbose", "debug"]).optional(),
33144
33170
  noUidChecks: zod.z.boolean().optional(),
33171
+ noHalt: zod.z.boolean().optional(),
33172
+ noIllegalProposal: zod.z.boolean().optional(),
33173
+ decompress: zod.z.enum(["never", "lossless", "lossy"]).optional(),
33174
+ multiAssociations: zod.z.boolean().optional(),
33175
+ createReportFile: zod.z.string().min(1).optional(),
33145
33176
  maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
33146
33177
  maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
33147
33178
  noHostnameLookup: zod.z.boolean().optional(),
@@ -33187,6 +33218,13 @@ function buildArgs33(options) {
33187
33218
  if (options.scanDirectory === true) {
33188
33219
  args.push("--scan-directories");
33189
33220
  }
33221
+ if (options.recurse === true) {
33222
+ args.push("+r");
33223
+ }
33224
+ if (options.scanPattern !== void 0) {
33225
+ args.push("--scan-pattern", options.scanPattern);
33226
+ }
33227
+ pushSendModeArgs(args, options);
33190
33228
  args.push(options.host, String(options.port));
33191
33229
  args.push(...options.files);
33192
33230
  return args;
@@ -36877,68 +36915,13 @@ var SenderHealth = {
36877
36915
  DOWN: "down"
36878
36916
  };
36879
36917
 
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;
36918
+ // src/senders/SenderEngine.ts
36888
36919
  var DEGRADE_THRESHOLD = 3;
36889
36920
  var DOWN_THRESHOLD = 10;
36890
36921
  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");
36922
+ var SenderEngine = class {
36923
+ constructor(config) {
36924
+ __publicField(this, "config");
36942
36925
  // Queue and concurrency state
36943
36926
  __publicField(this, "queue", []);
36944
36927
  __publicField(this, "activeAssociations", 0);
@@ -36951,113 +36934,36 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
36951
36934
  // Bucket state (bucket mode only)
36952
36935
  __publicField(this, "currentBucket", []);
36953
36936
  __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
- }
36937
+ this.config = config;
36938
+ this.effectiveMaxAssociations = config.configuredMaxAssociations;
36973
36939
  }
36974
36940
  // -----------------------------------------------------------------------
36975
36941
  // Public API
36976
36942
  // -----------------------------------------------------------------------
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) {
36943
+ /** Sends files through the engine's queue/bucket system. */
36944
+ send(files, timeoutMs, maxRetries, binaryParams) {
37006
36945
  if (this.isStopped) {
37007
- return Promise.resolve(err(new Error("DicomSender: sender is stopped")));
36946
+ return Promise.resolve(err(new Error(`${this.config.senderName}: sender is stopped`)));
37008
36947
  }
37009
36948
  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);
36949
+ return Promise.resolve(err(new Error(`${this.config.senderName}: no files provided`)));
37036
36950
  }
36951
+ return this.dispatchSend(files, timeoutMs, maxRetries, binaryParams);
37037
36952
  }
37038
- /**
37039
- * Flushes the current bucket immediately (bucket mode only).
37040
- * In single/multiple mode this is a no-op.
37041
- */
36953
+ /** Flushes the current bucket immediately (bucket mode only). */
37042
36954
  flush() {
37043
- if (this.mode !== "bucket") return;
36955
+ if (this.config.mode !== "bucket") return;
37044
36956
  if (this.currentBucket.length === 0) return;
37045
36957
  this.clearBucketTimer();
37046
36958
  this.flushBucketInternal("timer");
37047
36959
  }
37048
- /**
37049
- * Gracefully stops the sender. Rejects all queued items and
37050
- * waits for active associations to complete.
37051
- */
36960
+ /** Gracefully stops the engine. Rejects all queued items and waits for active associations. */
37052
36961
  async stop() {
37053
36962
  if (this.isStopped) return;
37054
36963
  this.isStopped = true;
37055
- if (this.options.signal !== void 0 && this.abortHandler !== void 0) {
37056
- this.options.signal.removeEventListener("abort", this.abortHandler);
37057
- }
37058
36964
  this.clearBucketTimer();
37059
- this.rejectBucket("DicomSender: sender stopped");
37060
- this.rejectQueue("DicomSender: sender stopped");
36965
+ this.rejectBucket(`${this.config.senderName}: sender stopped`);
36966
+ this.rejectQueue(`${this.config.senderName}: sender stopped`);
37061
36967
  await this.waitForActive();
37062
36968
  }
37063
36969
  /** Current sender status. */
@@ -37072,77 +36978,37 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37072
36978
  stopped: this.isStopped
37073
36979
  };
37074
36980
  }
36981
+ /** Whether the engine has been stopped. */
36982
+ get stopped() {
36983
+ return this.isStopped;
36984
+ }
37075
36985
  // -----------------------------------------------------------------------
37076
- // Typed event listener convenience methods
36986
+ // Dispatch
37077
36987
  // -----------------------------------------------------------------------
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);
36988
+ /** Dispatches a send to the appropriate mode handler. */
36989
+ dispatchSend(files, timeoutMs, maxRetries, binaryParams) {
36990
+ switch (this.config.mode) {
36991
+ case "single":
36992
+ case "multiple":
36993
+ return this.enqueueSend(files, timeoutMs, maxRetries, binaryParams);
36994
+ case "bucket":
36995
+ return this.enqueueBucket(files, timeoutMs, maxRetries, binaryParams);
36996
+ default:
36997
+ assertUnreachable(this.config.mode);
36998
+ }
37123
36999
  }
37124
37000
  // -----------------------------------------------------------------------
37125
37001
  // Single/Multiple mode: queue-based dispatch
37126
37002
  // -----------------------------------------------------------------------
37127
37003
  /** Enqueues a send and dispatches immediately if capacity allows. */
37128
- enqueueSend(files, params) {
37004
+ enqueueSend(files, timeoutMs, maxRetries, binaryParams) {
37129
37005
  return new Promise((resolve) => {
37130
37006
  const totalQueued = this.queue.length + this.currentBucket.length;
37131
- if (totalQueued >= this.maxQueueLength) {
37132
- resolve(err(new Error("DicomSender: queue full")));
37007
+ if (totalQueued >= this.config.maxQueueLength) {
37008
+ resolve(err(new Error(`${this.config.senderName}: queue full`)));
37133
37009
  return;
37134
37010
  }
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
- };
37011
+ const entry = { files, timeoutMs, maxRetries, binaryParams, resolve };
37146
37012
  if (this.activeAssociations < this.effectiveMaxAssociations) {
37147
37013
  void this.executeEntry(entry);
37148
37014
  } else {
@@ -37162,27 +37028,16 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37162
37028
  // Bucket mode: accumulate-then-flush
37163
37029
  // -----------------------------------------------------------------------
37164
37030
  /** Adds files to the current bucket and triggers flush if full. */
37165
- enqueueBucket(files, params) {
37031
+ enqueueBucket(files, timeoutMs, maxRetries, binaryParams) {
37166
37032
  return new Promise((resolve) => {
37167
37033
  const totalQueued = this.queue.length + this.currentBucket.length;
37168
- if (totalQueued >= this.maxQueueLength) {
37169
- resolve(err(new Error("DicomSender: queue full")));
37034
+ if (totalQueued >= this.config.maxQueueLength) {
37035
+ resolve(err(new Error(`${this.config.senderName}: queue full`)));
37170
37036
  return;
37171
37037
  }
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
- });
37038
+ this.currentBucket.push({ files, resolve, timeoutMs, maxRetries, binaryParams });
37184
37039
  const totalFiles = this.countBucketFiles();
37185
- if (totalFiles >= this.maxBucketSize) {
37040
+ if (totalFiles >= this.config.maxBucketSize) {
37186
37041
  this.clearBucketTimer();
37187
37042
  void this.flushBucketInternal("maxSize");
37188
37043
  } else {
@@ -37214,11 +37069,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37214
37069
  files: allFiles,
37215
37070
  timeoutMs,
37216
37071
  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,
37072
+ binaryParams: entries[0].binaryParams,
37222
37073
  resolve: (result) => {
37223
37074
  for (let i = 0; i < entries.length; i++) {
37224
37075
  entries[i].resolve(result);
@@ -37232,7 +37083,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37232
37083
  const entries = [...this.currentBucket];
37233
37084
  this.currentBucket = [];
37234
37085
  const bucketEntry = this.mergeBucketEntries(entries);
37235
- this.emit("BUCKET_FLUSHED", { fileCount: bucketEntry.files.length, reason });
37086
+ this.config.emitters.emitBucketFlushed({ fileCount: bucketEntry.files.length, reason });
37236
37087
  if (this.activeAssociations < this.effectiveMaxAssociations) {
37237
37088
  void this.executeEntry(bucketEntry);
37238
37089
  } else {
@@ -37245,7 +37096,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37245
37096
  this.bucketTimer = setTimeout(() => {
37246
37097
  this.bucketTimer = void 0;
37247
37098
  void this.flushBucketInternal("timer");
37248
- }, this.bucketFlushMs);
37099
+ }, this.config.bucketFlushMs);
37249
37100
  }
37250
37101
  /** Clears the bucket flush timer. */
37251
37102
  clearBucketTimer() {
@@ -37257,7 +37108,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37257
37108
  // -----------------------------------------------------------------------
37258
37109
  // Core send execution with retry
37259
37110
  // -----------------------------------------------------------------------
37260
- /** Executes a single queue entry: calls storescu with retry. */
37111
+ /** Executes a single queue entry: calls the binary with retry. */
37261
37112
  async executeEntry(entry) {
37262
37113
  this.activeAssociations++;
37263
37114
  const startMs = Date.now();
@@ -37266,7 +37117,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37266
37117
  if (failure === void 0) return;
37267
37118
  this.activeAssociations--;
37268
37119
  this.recordFailure();
37269
- this.emit("SEND_FAILED", {
37120
+ this.config.emitters.emitSendFailed({
37270
37121
  files: entry.files,
37271
37122
  error: failure.error,
37272
37123
  attempts: maxAttempts,
@@ -37276,49 +37127,27 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37276
37127
  entry.resolve(err(failure.error));
37277
37128
  this.drainQueue();
37278
37129
  }
37279
- /** Attempts storescu up to maxAttempts times. Returns undefined on success, or failure info. */
37130
+ /** Attempts the binary call up to maxAttempts times. Returns undefined on success. */
37280
37131
  async attemptSend(entry, maxAttempts, startMs) {
37281
37132
  let lastError;
37282
37133
  const lastOutput = { stdout: "", stderr: "" };
37283
37134
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
37284
37135
  if (this.isStopped) {
37285
37136
  this.activeAssociations--;
37286
- entry.resolve(err(new Error("DicomSender: sender stopped")));
37137
+ entry.resolve(err(new Error(`${this.config.senderName}: sender stopped`)));
37287
37138
  return void 0;
37288
37139
  }
37289
- const result = await this.callStorescu(entry);
37140
+ const result = await this.config.executor(entry.files, entry.timeoutMs, entry.binaryParams, this.config.signal);
37290
37141
  if (result.ok) {
37291
37142
  this.handleSendSuccess(entry, startMs, result.value);
37292
37143
  return void 0;
37293
37144
  }
37294
37145
  lastError = result.error;
37295
37146
  if (attempt < maxAttempts - 1) {
37296
- await delay2(this.retryDelayMs * (attempt + 1));
37147
+ await delay2(this.config.retryDelayMs * (attempt + 1));
37297
37148
  }
37298
37149
  }
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
- });
37150
+ return { error: lastError ?? new Error(`${this.config.senderName}: send failed`), output: lastOutput };
37322
37151
  }
37323
37152
  /** Handles a successful send: updates state, emits event, resolves promise. */
37324
37153
  handleSendSuccess(entry, startMs, output) {
@@ -37326,7 +37155,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37326
37155
  const durationMs = Date.now() - startMs;
37327
37156
  this.recordSuccess();
37328
37157
  const data = { files: entry.files, fileCount: entry.files.length, durationMs, stdout: output.stdout, stderr: output.stderr };
37329
- this.emit("SEND_COMPLETE", data);
37158
+ this.config.emitters.emitSendComplete(data);
37330
37159
  entry.resolve(ok(data));
37331
37160
  this.drainQueue();
37332
37161
  }
@@ -37344,8 +37173,8 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37344
37173
  if (this.health === SenderHealth.DOWN) {
37345
37174
  this.health = SenderHealth.DEGRADED;
37346
37175
  } else {
37347
- this.effectiveMaxAssociations = Math.min(this.effectiveMaxAssociations * 2, this.configuredMaxAssociations);
37348
- if (this.effectiveMaxAssociations >= this.configuredMaxAssociations) {
37176
+ this.effectiveMaxAssociations = Math.min(this.effectiveMaxAssociations * 2, this.config.configuredMaxAssociations);
37177
+ if (this.effectiveMaxAssociations >= this.config.configuredMaxAssociations) {
37349
37178
  this.health = SenderHealth.HEALTHY;
37350
37179
  }
37351
37180
  }
@@ -37365,22 +37194,26 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37365
37194
  this.emitHealthChanged(previousHealth);
37366
37195
  }
37367
37196
  } 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));
37197
+ this.degradeHealth(previousHealth);
37198
+ }
37199
+ }
37200
+ /** Handles HEALTHY→DEGRADED or DEGRADED→DEGRADED transitions. */
37201
+ degradeHealth(previousHealth) {
37202
+ if (this.health === SenderHealth.HEALTHY) {
37203
+ this.health = SenderHealth.DEGRADED;
37204
+ this.effectiveMaxAssociations = Math.max(1, Math.floor(this.config.configuredMaxAssociations / 2));
37205
+ this.emitHealthChanged(previousHealth);
37206
+ } else if (this.health === SenderHealth.DEGRADED) {
37207
+ const newMax = Math.max(1, Math.floor(this.effectiveMaxAssociations / 2));
37208
+ if (newMax !== this.effectiveMaxAssociations) {
37209
+ this.effectiveMaxAssociations = newMax;
37371
37210
  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
37211
  }
37379
37212
  }
37380
37213
  }
37381
- /** Emits a HEALTH_CHANGED event. */
37214
+ /** Emits a HEALTH_CHANGED event via the wrapper's emitter. */
37382
37215
  emitHealthChanged(previousHealth) {
37383
- this.emit("HEALTH_CHANGED", {
37216
+ this.config.emitters.emitHealthChanged({
37384
37217
  previousHealth,
37385
37218
  newHealth: this.health,
37386
37219
  effectiveMaxAssociations: this.effectiveMaxAssociations,
@@ -37420,20 +37253,6 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37420
37253
  check();
37421
37254
  });
37422
37255
  }
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
37256
  };
37438
37257
  function delay2(ms) {
37439
37258
  return new Promise((resolve) => {
@@ -37441,6 +37260,492 @@ function delay2(ms) {
37441
37260
  });
37442
37261
  }
37443
37262
 
37263
+ // src/senders/DicomSender.ts
37264
+ var DEFAULT_MAX_ASSOCIATIONS = 4;
37265
+ var DEFAULT_MAX_QUEUE_LENGTH = 1e3;
37266
+ var DEFAULT_MAX_RETRIES = 3;
37267
+ var DEFAULT_RETRY_DELAY_MS = 1e3;
37268
+ var DEFAULT_BUCKET_FLUSH_MS = 5e3;
37269
+ var DEFAULT_MAX_BUCKET_SIZE = 50;
37270
+ var MAX_ASSOCIATIONS_LIMIT = 64;
37271
+ var DicomSenderOptionsSchema = zod.z.object({
37272
+ host: zod.z.string().min(1),
37273
+ port: zod.z.number().int().min(1).max(65535),
37274
+ calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37275
+ callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37276
+ mode: zod.z.enum(["single", "multiple", "bucket"]).optional(),
37277
+ maxAssociations: zod.z.number().int().min(1).max(MAX_ASSOCIATIONS_LIMIT).optional(),
37278
+ proposedTransferSyntax: zod.z.union([zod.z.enum(PROPOSED_TS_VALUES), zod.z.array(zod.z.enum(PROPOSED_TS_VALUES)).min(1)]).optional(),
37279
+ combineProposedTransferSyntaxes: zod.z.boolean().optional(),
37280
+ maxQueueLength: zod.z.number().int().min(1).optional(),
37281
+ timeoutMs: zod.z.number().int().positive().optional(),
37282
+ maxRetries: zod.z.number().int().min(0).optional(),
37283
+ retryDelayMs: zod.z.number().int().min(0).optional(),
37284
+ bucketFlushMs: zod.z.number().int().positive().optional(),
37285
+ maxBucketSize: zod.z.number().int().min(1).optional(),
37286
+ maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
37287
+ maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
37288
+ associationTimeout: zod.z.number().int().positive().optional(),
37289
+ acseTimeout: zod.z.number().int().positive().optional(),
37290
+ dimseTimeout: zod.z.number().int().positive().optional(),
37291
+ noHostnameLookup: zod.z.boolean().optional(),
37292
+ verbosity: zod.z.enum(["verbose", "debug"]).optional(),
37293
+ required: zod.z.boolean().optional(),
37294
+ signal: zod.z.instanceof(AbortSignal).optional()
37295
+ }).strict();
37296
+ function createStorescuExecutor(options) {
37297
+ return async (files, timeoutMs, params, signal) => {
37298
+ const result = await storescu({
37299
+ host: options.host,
37300
+ port: options.port,
37301
+ files: [...files],
37302
+ calledAETitle: params.calledAETitle ?? options.calledAETitle,
37303
+ callingAETitle: params.callingAETitle ?? options.callingAETitle,
37304
+ proposedTransferSyntax: params.proposedTransferSyntax ?? options.proposedTransferSyntax,
37305
+ combineProposedTransferSyntaxes: params.combineProposedTransferSyntaxes ?? options.combineProposedTransferSyntaxes,
37306
+ maxPduReceive: options.maxPduReceive,
37307
+ maxPduSend: options.maxPduSend,
37308
+ associationTimeout: options.associationTimeout,
37309
+ acseTimeout: options.acseTimeout,
37310
+ dimseTimeout: options.dimseTimeout,
37311
+ noHostnameLookup: options.noHostnameLookup,
37312
+ verbosity: options.verbosity,
37313
+ required: params.required ?? options.required,
37314
+ timeoutMs,
37315
+ signal
37316
+ });
37317
+ if (!result.ok) return err(result.error);
37318
+ return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37319
+ };
37320
+ }
37321
+ function resolveConfig(options) {
37322
+ const mode = options.mode ?? "multiple";
37323
+ const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS;
37324
+ return {
37325
+ mode,
37326
+ configuredMaxAssociations: mode === "single" ? 1 : rawMax,
37327
+ maxQueueLength: options.maxQueueLength ?? DEFAULT_MAX_QUEUE_LENGTH,
37328
+ defaultTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
37329
+ defaultMaxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
37330
+ retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
37331
+ bucketFlushMs: options.bucketFlushMs ?? DEFAULT_BUCKET_FLUSH_MS,
37332
+ maxBucketSize: options.maxBucketSize ?? DEFAULT_MAX_BUCKET_SIZE
37333
+ };
37334
+ }
37335
+ var DicomSender = class _DicomSender extends events.EventEmitter {
37336
+ constructor(engine, cfg, signal) {
37337
+ super();
37338
+ __publicField(this, "engine");
37339
+ __publicField(this, "defaultTimeoutMs");
37340
+ __publicField(this, "defaultMaxRetries");
37341
+ __publicField(this, "signal");
37342
+ __publicField(this, "abortHandler");
37343
+ this.setMaxListeners(20);
37344
+ this.on("error", () => {
37345
+ });
37346
+ this.engine = engine;
37347
+ this.defaultTimeoutMs = cfg.defaultTimeoutMs;
37348
+ this.defaultMaxRetries = cfg.defaultMaxRetries;
37349
+ this.signal = signal;
37350
+ if (signal !== void 0) {
37351
+ this.wireAbortSignal(signal);
37352
+ }
37353
+ }
37354
+ // -----------------------------------------------------------------------
37355
+ // Public API
37356
+ // -----------------------------------------------------------------------
37357
+ /**
37358
+ * Creates a new DicomSender instance.
37359
+ *
37360
+ * @param options - Configuration options
37361
+ * @returns A Result containing the instance or a validation error
37362
+ */
37363
+ static create(options) {
37364
+ const validation = DicomSenderOptionsSchema.safeParse(options);
37365
+ if (!validation.success) {
37366
+ return err(createValidationError("DicomSender", validation.error));
37367
+ }
37368
+ const cfg = resolveConfig(options);
37369
+ const senderRef = { current: void 0 };
37370
+ const engine = new SenderEngine({
37371
+ ...cfg,
37372
+ executor: createStorescuExecutor(options),
37373
+ signal: options.signal,
37374
+ senderName: "DicomSender",
37375
+ emitters: {
37376
+ emitSendComplete: (data) => {
37377
+ senderRef.current.emit("SEND_COMPLETE", data);
37378
+ },
37379
+ emitSendFailed: (data) => {
37380
+ senderRef.current.emit("SEND_FAILED", data);
37381
+ },
37382
+ emitHealthChanged: (data) => {
37383
+ senderRef.current.emit("HEALTH_CHANGED", data);
37384
+ },
37385
+ emitBucketFlushed: (data) => {
37386
+ senderRef.current.emit("BUCKET_FLUSHED", data);
37387
+ }
37388
+ }
37389
+ });
37390
+ const sender = new _DicomSender(engine, cfg, options.signal);
37391
+ senderRef.current = sender;
37392
+ return ok(sender);
37393
+ }
37394
+ /**
37395
+ * Sends one or more DICOM files to the remote endpoint.
37396
+ *
37397
+ * In single/multiple mode, files are sent as one storescu call.
37398
+ * In bucket mode, files are accumulated into a bucket and flushed
37399
+ * when the bucket reaches maxBucketSize or the flush timer fires.
37400
+ *
37401
+ * The returned promise resolves when the files are actually sent
37402
+ * (not just queued). Callers can await for confirmation or
37403
+ * fire-and-forget with `void sender.send(files)`.
37404
+ *
37405
+ * @param files - One or more DICOM file paths
37406
+ * @param options - Per-send overrides
37407
+ * @returns A Result containing the send result or an error
37408
+ */
37409
+ send(files, options) {
37410
+ const params = buildStorescuParams(options);
37411
+ const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
37412
+ const maxRetries = options?.maxRetries ?? this.defaultMaxRetries;
37413
+ return this.engine.send(files, timeoutMs, maxRetries, params);
37414
+ }
37415
+ /**
37416
+ * Flushes the current bucket immediately (bucket mode only).
37417
+ * In single/multiple mode this is a no-op.
37418
+ */
37419
+ flush() {
37420
+ this.engine.flush();
37421
+ }
37422
+ /**
37423
+ * Gracefully stops the sender. Rejects all queued items and
37424
+ * waits for active associations to complete.
37425
+ */
37426
+ async stop() {
37427
+ if (this.signal !== void 0 && this.abortHandler !== void 0) {
37428
+ this.signal.removeEventListener("abort", this.abortHandler);
37429
+ }
37430
+ await this.engine.stop();
37431
+ }
37432
+ /** Current sender status. */
37433
+ get status() {
37434
+ return this.engine.status;
37435
+ }
37436
+ // -----------------------------------------------------------------------
37437
+ // Typed event listener convenience methods
37438
+ // -----------------------------------------------------------------------
37439
+ /**
37440
+ * Registers a typed listener for a DicomSender-specific event.
37441
+ *
37442
+ * @param event - The event name from DicomSenderEventMap
37443
+ * @param listener - Callback receiving typed event data
37444
+ * @returns this for chaining
37445
+ */
37446
+ onEvent(event, listener) {
37447
+ return this.on(event, listener);
37448
+ }
37449
+ /**
37450
+ * Registers a listener for successful sends.
37451
+ *
37452
+ * @param listener - Callback receiving send complete data
37453
+ * @returns this for chaining
37454
+ */
37455
+ onSendComplete(listener) {
37456
+ return this.on("SEND_COMPLETE", listener);
37457
+ }
37458
+ /**
37459
+ * Registers a listener for failed sends.
37460
+ *
37461
+ * @param listener - Callback receiving send failed data
37462
+ * @returns this for chaining
37463
+ */
37464
+ onSendFailed(listener) {
37465
+ return this.on("SEND_FAILED", listener);
37466
+ }
37467
+ /**
37468
+ * Registers a listener for health state changes.
37469
+ *
37470
+ * @param listener - Callback receiving health change data
37471
+ * @returns this for chaining
37472
+ */
37473
+ onHealthChanged(listener) {
37474
+ return this.on("HEALTH_CHANGED", listener);
37475
+ }
37476
+ /**
37477
+ * Registers a listener for bucket flushes (bucket mode only).
37478
+ *
37479
+ * @param listener - Callback receiving bucket flush data
37480
+ * @returns this for chaining
37481
+ */
37482
+ onBucketFlushed(listener) {
37483
+ return this.on("BUCKET_FLUSHED", listener);
37484
+ }
37485
+ // -----------------------------------------------------------------------
37486
+ // Abort signal
37487
+ // -----------------------------------------------------------------------
37488
+ /** Wires an AbortSignal to stop the sender. */
37489
+ wireAbortSignal(signal) {
37490
+ if (signal.aborted) {
37491
+ void this.stop();
37492
+ return;
37493
+ }
37494
+ this.abortHandler = () => {
37495
+ void this.stop();
37496
+ };
37497
+ signal.addEventListener("abort", this.abortHandler, { once: true });
37498
+ }
37499
+ };
37500
+ function buildStorescuParams(options) {
37501
+ return {
37502
+ calledAETitle: options?.calledAETitle,
37503
+ callingAETitle: options?.callingAETitle,
37504
+ required: options?.required,
37505
+ proposedTransferSyntax: options?.proposedTransferSyntax,
37506
+ combineProposedTransferSyntaxes: options?.combineProposedTransferSyntaxes
37507
+ };
37508
+ }
37509
+ var DEFAULT_MAX_ASSOCIATIONS2 = 4;
37510
+ var DEFAULT_MAX_QUEUE_LENGTH2 = 1e3;
37511
+ var DEFAULT_MAX_RETRIES2 = 3;
37512
+ var DEFAULT_RETRY_DELAY_MS2 = 1e3;
37513
+ var DEFAULT_BUCKET_FLUSH_MS2 = 5e3;
37514
+ var DEFAULT_MAX_BUCKET_SIZE2 = 50;
37515
+ var MAX_ASSOCIATIONS_LIMIT2 = 64;
37516
+ var DicomSendOptionsSchema = zod.z.object({
37517
+ host: zod.z.string().min(1),
37518
+ port: zod.z.number().int().min(1).max(65535),
37519
+ calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37520
+ callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
37521
+ mode: zod.z.enum(["single", "multiple", "bucket"]).optional(),
37522
+ maxAssociations: zod.z.number().int().min(1).max(MAX_ASSOCIATIONS_LIMIT2).optional(),
37523
+ maxQueueLength: zod.z.number().int().min(1).optional(),
37524
+ timeoutMs: zod.z.number().int().positive().optional(),
37525
+ maxRetries: zod.z.number().int().min(0).optional(),
37526
+ retryDelayMs: zod.z.number().int().min(0).optional(),
37527
+ bucketFlushMs: zod.z.number().int().positive().optional(),
37528
+ maxBucketSize: zod.z.number().int().min(1).optional(),
37529
+ maxPduReceive: zod.z.number().int().min(4096).max(131072).optional(),
37530
+ maxPduSend: zod.z.number().int().min(4096).max(131072).optional(),
37531
+ associationTimeout: zod.z.number().int().positive().optional(),
37532
+ acseTimeout: zod.z.number().int().positive().optional(),
37533
+ dimseTimeout: zod.z.number().int().positive().optional(),
37534
+ noHostnameLookup: zod.z.boolean().optional(),
37535
+ verbosity: zod.z.enum(["verbose", "debug"]).optional(),
37536
+ noHalt: zod.z.boolean().optional(),
37537
+ noIllegalProposal: zod.z.boolean().optional(),
37538
+ decompress: zod.z.enum(["never", "lossless", "lossy"]).optional(),
37539
+ multiAssociations: zod.z.boolean().optional(),
37540
+ noUidChecks: zod.z.boolean().optional(),
37541
+ signal: zod.z.instanceof(AbortSignal).optional()
37542
+ }).strict();
37543
+ function createDcmsendExecutor(options) {
37544
+ return async (files, timeoutMs, params, signal) => {
37545
+ const result = await dcmsend({
37546
+ host: options.host,
37547
+ port: options.port,
37548
+ files: [...files],
37549
+ calledAETitle: params.calledAETitle ?? options.calledAETitle,
37550
+ callingAETitle: params.callingAETitle ?? options.callingAETitle,
37551
+ maxPduReceive: options.maxPduReceive,
37552
+ maxPduSend: options.maxPduSend,
37553
+ associationTimeout: options.associationTimeout,
37554
+ acseTimeout: options.acseTimeout,
37555
+ dimseTimeout: options.dimseTimeout,
37556
+ noHostnameLookup: options.noHostnameLookup,
37557
+ verbosity: options.verbosity,
37558
+ noHalt: options.noHalt,
37559
+ noIllegalProposal: options.noIllegalProposal,
37560
+ decompress: options.decompress,
37561
+ multiAssociations: options.multiAssociations,
37562
+ noUidChecks: options.noUidChecks,
37563
+ timeoutMs,
37564
+ signal
37565
+ });
37566
+ if (!result.ok) return err(result.error);
37567
+ return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37568
+ };
37569
+ }
37570
+ function resolveConfig2(options) {
37571
+ const mode = options.mode ?? "multiple";
37572
+ const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS2;
37573
+ return {
37574
+ mode,
37575
+ configuredMaxAssociations: mode === "single" ? 1 : rawMax,
37576
+ maxQueueLength: options.maxQueueLength ?? DEFAULT_MAX_QUEUE_LENGTH2,
37577
+ defaultTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
37578
+ defaultMaxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES2,
37579
+ retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS2,
37580
+ bucketFlushMs: options.bucketFlushMs ?? DEFAULT_BUCKET_FLUSH_MS2,
37581
+ maxBucketSize: options.maxBucketSize ?? DEFAULT_MAX_BUCKET_SIZE2
37582
+ };
37583
+ }
37584
+ var DicomSend = class _DicomSend extends events.EventEmitter {
37585
+ constructor(engine, cfg, signal) {
37586
+ super();
37587
+ __publicField(this, "engine");
37588
+ __publicField(this, "defaultTimeoutMs");
37589
+ __publicField(this, "defaultMaxRetries");
37590
+ __publicField(this, "signal");
37591
+ __publicField(this, "abortHandler");
37592
+ this.setMaxListeners(20);
37593
+ this.on("error", () => {
37594
+ });
37595
+ this.engine = engine;
37596
+ this.defaultTimeoutMs = cfg.defaultTimeoutMs;
37597
+ this.defaultMaxRetries = cfg.defaultMaxRetries;
37598
+ this.signal = signal;
37599
+ if (signal !== void 0) {
37600
+ this.wireAbortSignal(signal);
37601
+ }
37602
+ }
37603
+ // -----------------------------------------------------------------------
37604
+ // Public API
37605
+ // -----------------------------------------------------------------------
37606
+ /**
37607
+ * Creates a new DicomSend instance.
37608
+ *
37609
+ * @param options - Configuration options
37610
+ * @returns A Result containing the instance or a validation error
37611
+ */
37612
+ static create(options) {
37613
+ const validation = DicomSendOptionsSchema.safeParse(options);
37614
+ if (!validation.success) {
37615
+ return err(createValidationError("DicomSend", validation.error));
37616
+ }
37617
+ const cfg = resolveConfig2(options);
37618
+ const senderRef = { current: void 0 };
37619
+ const engine = new SenderEngine({
37620
+ ...cfg,
37621
+ executor: createDcmsendExecutor(options),
37622
+ signal: options.signal,
37623
+ senderName: "DicomSend",
37624
+ emitters: {
37625
+ emitSendComplete: (data) => {
37626
+ senderRef.current.emit("SEND_COMPLETE", data);
37627
+ },
37628
+ emitSendFailed: (data) => {
37629
+ senderRef.current.emit("SEND_FAILED", data);
37630
+ },
37631
+ emitHealthChanged: (data) => {
37632
+ senderRef.current.emit("HEALTH_CHANGED", data);
37633
+ },
37634
+ emitBucketFlushed: (data) => {
37635
+ senderRef.current.emit("BUCKET_FLUSHED", data);
37636
+ }
37637
+ }
37638
+ });
37639
+ const sender = new _DicomSend(engine, cfg, options.signal);
37640
+ senderRef.current = sender;
37641
+ return ok(sender);
37642
+ }
37643
+ /**
37644
+ * Sends one or more DICOM files to the remote endpoint.
37645
+ *
37646
+ * In single/multiple mode, files are sent as one dcmsend call.
37647
+ * In bucket mode, files are accumulated into a bucket and flushed
37648
+ * when the bucket reaches maxBucketSize or the flush timer fires.
37649
+ *
37650
+ * @param files - One or more DICOM file paths
37651
+ * @param options - Per-send overrides
37652
+ * @returns A Result containing the send result or an error
37653
+ */
37654
+ send(files, options) {
37655
+ const params = {
37656
+ calledAETitle: options?.calledAETitle,
37657
+ callingAETitle: options?.callingAETitle
37658
+ };
37659
+ const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
37660
+ const maxRetries = options?.maxRetries ?? this.defaultMaxRetries;
37661
+ return this.engine.send(files, timeoutMs, maxRetries, params);
37662
+ }
37663
+ /**
37664
+ * Flushes the current bucket immediately (bucket mode only).
37665
+ * In single/multiple mode this is a no-op.
37666
+ */
37667
+ flush() {
37668
+ this.engine.flush();
37669
+ }
37670
+ /**
37671
+ * Gracefully stops the sender. Rejects all queued items and
37672
+ * waits for active associations to complete.
37673
+ */
37674
+ async stop() {
37675
+ if (this.signal !== void 0 && this.abortHandler !== void 0) {
37676
+ this.signal.removeEventListener("abort", this.abortHandler);
37677
+ }
37678
+ await this.engine.stop();
37679
+ }
37680
+ /** Current sender status. */
37681
+ get status() {
37682
+ return this.engine.status;
37683
+ }
37684
+ // -----------------------------------------------------------------------
37685
+ // Typed event listener convenience methods
37686
+ // -----------------------------------------------------------------------
37687
+ /**
37688
+ * Registers a typed listener for a DicomSend-specific event.
37689
+ *
37690
+ * @param event - The event name from DicomSendEventMap
37691
+ * @param listener - Callback receiving typed event data
37692
+ * @returns this for chaining
37693
+ */
37694
+ onEvent(event, listener) {
37695
+ return this.on(event, listener);
37696
+ }
37697
+ /**
37698
+ * Registers a listener for successful sends.
37699
+ *
37700
+ * @param listener - Callback receiving send complete data
37701
+ * @returns this for chaining
37702
+ */
37703
+ onSendComplete(listener) {
37704
+ return this.on("SEND_COMPLETE", listener);
37705
+ }
37706
+ /**
37707
+ * Registers a listener for failed sends.
37708
+ *
37709
+ * @param listener - Callback receiving send failed data
37710
+ * @returns this for chaining
37711
+ */
37712
+ onSendFailed(listener) {
37713
+ return this.on("SEND_FAILED", listener);
37714
+ }
37715
+ /**
37716
+ * Registers a listener for health state changes.
37717
+ *
37718
+ * @param listener - Callback receiving health change data
37719
+ * @returns this for chaining
37720
+ */
37721
+ onHealthChanged(listener) {
37722
+ return this.on("HEALTH_CHANGED", listener);
37723
+ }
37724
+ /**
37725
+ * Registers a listener for bucket flushes (bucket mode only).
37726
+ *
37727
+ * @param listener - Callback receiving bucket flush data
37728
+ * @returns this for chaining
37729
+ */
37730
+ onBucketFlushed(listener) {
37731
+ return this.on("BUCKET_FLUSHED", listener);
37732
+ }
37733
+ // -----------------------------------------------------------------------
37734
+ // Abort signal
37735
+ // -----------------------------------------------------------------------
37736
+ /** Wires an AbortSignal to stop the sender. */
37737
+ wireAbortSignal(signal) {
37738
+ if (signal.aborted) {
37739
+ void this.stop();
37740
+ return;
37741
+ }
37742
+ this.abortHandler = () => {
37743
+ void this.stop();
37744
+ };
37745
+ signal.addEventListener("abort", this.abortHandler, { once: true });
37746
+ }
37747
+ };
37748
+
37444
37749
  // src/pacs/types.ts
37445
37750
  var QueryLevel = {
37446
37751
  STUDY: "STUDY",
@@ -37990,7 +38295,7 @@ var DEFAULT_CONFIG = {
37990
38295
  signal: void 0,
37991
38296
  onRetry: void 0
37992
38297
  };
37993
- function resolveConfig2(opts) {
38298
+ function resolveConfig3(opts) {
37994
38299
  if (!opts) return DEFAULT_CONFIG;
37995
38300
  return {
37996
38301
  ...DEFAULT_CONFIG,
@@ -38035,7 +38340,7 @@ function shouldBreakAfterFailure(attempt, lastError, config) {
38035
38340
  return false;
38036
38341
  }
38037
38342
  async function retry(operation, options) {
38038
- const config = resolveConfig2(options);
38343
+ const config = resolveConfig3(options);
38039
38344
  let lastResult = err(new Error("No attempts executed"));
38040
38345
  for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
38041
38346
  lastResult = await operation();
@@ -38083,6 +38388,7 @@ exports.DcmtkProcess = DcmtkProcess;
38083
38388
  exports.DicomDataset = DicomDataset;
38084
38389
  exports.DicomInstance = DicomInstance;
38085
38390
  exports.DicomReceiver = DicomReceiver;
38391
+ exports.DicomSend = DicomSend;
38086
38392
  exports.DicomSender = DicomSender;
38087
38393
  exports.DicomTagPathSchema = DicomTagPathSchema;
38088
38394
  exports.DicomTagSchema = DicomTagSchema;