@ubercode/dcmtk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -61,7 +61,7 @@ function mapResult(result, fn) {
61
61
 
62
62
  // src/patterns.ts
63
63
  var DICOM_TAG_PATTERN = /^\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)$/;
64
- var AE_TITLE_PATTERN = /^[A-Za-z0-9 -]+$/;
64
+ var AE_TITLE_PATTERN = /^[\x20-\x5b\x5d-\x7e]+$/;
65
65
  var UID_PATTERN = /^[0-9]+(\.[0-9]+)*$/;
66
66
  var TAG_PATH_SEGMENT = /\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\])?/;
67
67
  var DICOM_TAG_PATH_PATTERN = new RegExp(`^${TAG_PATH_SEGMENT.source}(\\.${TAG_PATH_SEGMENT.source})*$`);
@@ -94,7 +94,7 @@ function createAETitle(input) {
94
94
  return err(new Error(`Invalid AE Title: "${input}". Must be ${AE_TITLE_MIN_LENGTH}-${AE_TITLE_MAX_LENGTH} characters`));
95
95
  }
96
96
  if (!AE_TITLE_PATTERN.test(input)) {
97
- return err(new Error(`Invalid AE Title: "${input}". Only letters, digits, spaces, and hyphens are allowed`));
97
+ return err(new Error(`Invalid AE Title: "${input}". Only printable ASCII characters (no backslash) are allowed`));
98
98
  }
99
99
  return ok(input);
100
100
  }
@@ -36017,6 +36017,532 @@ function delay(ms) {
36017
36017
  });
36018
36018
  }
36019
36019
 
36020
+ // src/senders/types.ts
36021
+ var SenderMode = {
36022
+ /** One association at a time, queued FIFO. */
36023
+ SINGLE: "single",
36024
+ /** Up to N concurrent associations, each send() gets its own. */
36025
+ MULTIPLE: "multiple",
36026
+ /** Files accumulated into buckets, each bucket = one association. */
36027
+ BUCKET: "bucket"
36028
+ };
36029
+ var SenderHealth = {
36030
+ /** All associations succeeding normally. */
36031
+ HEALTHY: "healthy",
36032
+ /** Recent failures detected; effective concurrency reduced. */
36033
+ DEGRADED: "degraded",
36034
+ /** Remote endpoint appears down; minimal concurrency. */
36035
+ DOWN: "down"
36036
+ };
36037
+
36038
+ // src/senders/DicomSender.ts
36039
+ var DEFAULT_MAX_ASSOCIATIONS = 4;
36040
+ var DEFAULT_MAX_QUEUE_LENGTH = 1e3;
36041
+ var DEFAULT_MAX_RETRIES = 3;
36042
+ var DEFAULT_RETRY_DELAY_MS = 1e3;
36043
+ var DEFAULT_BUCKET_FLUSH_MS = 5e3;
36044
+ var DEFAULT_MAX_BUCKET_SIZE = 50;
36045
+ var MAX_ASSOCIATIONS_LIMIT = 64;
36046
+ var DEGRADE_THRESHOLD = 3;
36047
+ var DOWN_THRESHOLD = 10;
36048
+ var RECOVERY_THRESHOLD = 3;
36049
+ var DicomSenderOptionsSchema = zod.z.object({
36050
+ host: zod.z.string().min(1),
36051
+ port: zod.z.number().int().min(1).max(65535),
36052
+ calledAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
36053
+ callingAETitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
36054
+ mode: zod.z.enum(["single", "multiple", "bucket"]).optional(),
36055
+ maxAssociations: zod.z.number().int().min(1).max(MAX_ASSOCIATIONS_LIMIT).optional(),
36056
+ proposedTransferSyntax: zod.z.enum([
36057
+ "uncompressed",
36058
+ "littleEndian",
36059
+ "bigEndian",
36060
+ "implicitVR",
36061
+ "jpegLossless",
36062
+ "jpeg8Bit",
36063
+ "jpeg12Bit",
36064
+ "j2kLossless",
36065
+ "j2kLossy",
36066
+ "jlsLossless",
36067
+ "jlsLossy"
36068
+ ]).optional(),
36069
+ maxQueueLength: zod.z.number().int().min(1).optional(),
36070
+ timeoutMs: zod.z.number().int().positive().optional(),
36071
+ maxRetries: zod.z.number().int().min(0).optional(),
36072
+ retryDelayMs: zod.z.number().int().min(0).optional(),
36073
+ bucketFlushMs: zod.z.number().int().positive().optional(),
36074
+ maxBucketSize: zod.z.number().int().min(1).optional(),
36075
+ signal: zod.z.instanceof(AbortSignal).optional()
36076
+ }).strict();
36077
+ function resolveConfig(options) {
36078
+ const mode = options.mode ?? "multiple";
36079
+ const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS;
36080
+ return {
36081
+ mode,
36082
+ configuredMaxAssociations: mode === "single" ? 1 : rawMax,
36083
+ maxQueueLength: options.maxQueueLength ?? DEFAULT_MAX_QUEUE_LENGTH,
36084
+ defaultTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
36085
+ defaultMaxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
36086
+ retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
36087
+ bucketFlushMs: options.bucketFlushMs ?? DEFAULT_BUCKET_FLUSH_MS,
36088
+ maxBucketSize: options.maxBucketSize ?? DEFAULT_MAX_BUCKET_SIZE
36089
+ };
36090
+ }
36091
+ var DicomSender = class _DicomSender extends events.EventEmitter {
36092
+ constructor(options) {
36093
+ super();
36094
+ __publicField(this, "options");
36095
+ __publicField(this, "mode");
36096
+ __publicField(this, "configuredMaxAssociations");
36097
+ __publicField(this, "maxQueueLength");
36098
+ __publicField(this, "defaultTimeoutMs");
36099
+ __publicField(this, "defaultMaxRetries");
36100
+ __publicField(this, "retryDelayMs");
36101
+ __publicField(this, "bucketFlushMs");
36102
+ __publicField(this, "maxBucketSize");
36103
+ // Queue and concurrency state
36104
+ __publicField(this, "queue", []);
36105
+ __publicField(this, "activeAssociations", 0);
36106
+ __publicField(this, "isStopped", false);
36107
+ // Backpressure state
36108
+ __publicField(this, "health", SenderHealth.HEALTHY);
36109
+ __publicField(this, "effectiveMaxAssociations");
36110
+ __publicField(this, "consecutiveFailures", 0);
36111
+ __publicField(this, "consecutiveSuccesses", 0);
36112
+ // Bucket state (bucket mode only)
36113
+ __publicField(this, "currentBucket", []);
36114
+ __publicField(this, "bucketTimer");
36115
+ // AbortSignal
36116
+ __publicField(this, "abortHandler");
36117
+ this.setMaxListeners(20);
36118
+ this.on("error", () => {
36119
+ });
36120
+ this.options = options;
36121
+ const cfg = resolveConfig(options);
36122
+ this.mode = cfg.mode;
36123
+ this.configuredMaxAssociations = cfg.configuredMaxAssociations;
36124
+ this.effectiveMaxAssociations = cfg.configuredMaxAssociations;
36125
+ this.maxQueueLength = cfg.maxQueueLength;
36126
+ this.defaultTimeoutMs = cfg.defaultTimeoutMs;
36127
+ this.defaultMaxRetries = cfg.defaultMaxRetries;
36128
+ this.retryDelayMs = cfg.retryDelayMs;
36129
+ this.bucketFlushMs = cfg.bucketFlushMs;
36130
+ this.maxBucketSize = cfg.maxBucketSize;
36131
+ if (options.signal !== void 0) {
36132
+ this.wireAbortSignal(options.signal);
36133
+ }
36134
+ }
36135
+ // -----------------------------------------------------------------------
36136
+ // Public API
36137
+ // -----------------------------------------------------------------------
36138
+ /**
36139
+ * Creates a new DicomSender instance.
36140
+ *
36141
+ * @param options - Configuration options
36142
+ * @returns A Result containing the instance or a validation error
36143
+ */
36144
+ static create(options) {
36145
+ const validation = DicomSenderOptionsSchema.safeParse(options);
36146
+ if (!validation.success) {
36147
+ return err(createValidationError("DicomSender", validation.error));
36148
+ }
36149
+ return ok(new _DicomSender(options));
36150
+ }
36151
+ /**
36152
+ * Sends one or more DICOM files to the remote endpoint.
36153
+ *
36154
+ * In single/multiple mode, files are sent as one storescu call.
36155
+ * In bucket mode, files are accumulated into a bucket and flushed
36156
+ * when the bucket reaches maxBucketSize or the flush timer fires.
36157
+ *
36158
+ * The returned promise resolves when the files are actually sent
36159
+ * (not just queued). Callers can await for confirmation or
36160
+ * fire-and-forget with `void sender.send(files)`.
36161
+ *
36162
+ * @param files - One or more DICOM file paths
36163
+ * @param options - Per-send overrides
36164
+ * @returns A Result containing the send result or an error
36165
+ */
36166
+ send(files, options) {
36167
+ if (this.isStopped) {
36168
+ return Promise.resolve(err(new Error("DicomSender: sender is stopped")));
36169
+ }
36170
+ if (files.length === 0) {
36171
+ return Promise.resolve(err(new Error("DicomSender: no files provided")));
36172
+ }
36173
+ const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
36174
+ const maxRetries = options?.maxRetries ?? this.defaultMaxRetries;
36175
+ switch (this.mode) {
36176
+ case "single":
36177
+ case "multiple":
36178
+ return this.enqueueSend(files, timeoutMs, maxRetries);
36179
+ case "bucket":
36180
+ return this.enqueueBucket(files, timeoutMs, maxRetries);
36181
+ default:
36182
+ assertUnreachable(this.mode);
36183
+ }
36184
+ }
36185
+ /**
36186
+ * Flushes the current bucket immediately (bucket mode only).
36187
+ * In single/multiple mode this is a no-op.
36188
+ */
36189
+ flush() {
36190
+ if (this.mode !== "bucket") return;
36191
+ if (this.currentBucket.length === 0) return;
36192
+ this.clearBucketTimer();
36193
+ this.flushBucketInternal("timer");
36194
+ }
36195
+ /**
36196
+ * Gracefully stops the sender. Rejects all queued items and
36197
+ * waits for active associations to complete.
36198
+ */
36199
+ async stop() {
36200
+ if (this.isStopped) return;
36201
+ this.isStopped = true;
36202
+ if (this.options.signal !== void 0 && this.abortHandler !== void 0) {
36203
+ this.options.signal.removeEventListener("abort", this.abortHandler);
36204
+ }
36205
+ this.clearBucketTimer();
36206
+ this.rejectBucket("DicomSender: sender stopped");
36207
+ this.rejectQueue("DicomSender: sender stopped");
36208
+ await this.waitForActive();
36209
+ }
36210
+ /** Current sender status. */
36211
+ get status() {
36212
+ return {
36213
+ health: this.health,
36214
+ activeAssociations: this.activeAssociations,
36215
+ effectiveMaxAssociations: this.effectiveMaxAssociations,
36216
+ queueLength: this.queue.length,
36217
+ consecutiveFailures: this.consecutiveFailures,
36218
+ consecutiveSuccesses: this.consecutiveSuccesses,
36219
+ stopped: this.isStopped
36220
+ };
36221
+ }
36222
+ // -----------------------------------------------------------------------
36223
+ // Typed event listener convenience methods
36224
+ // -----------------------------------------------------------------------
36225
+ /**
36226
+ * Registers a typed listener for a DicomSender-specific event.
36227
+ *
36228
+ * @param event - The event name from DicomSenderEventMap
36229
+ * @param listener - Callback receiving typed event data
36230
+ * @returns this for chaining
36231
+ */
36232
+ onEvent(event, listener) {
36233
+ return this.on(event, listener);
36234
+ }
36235
+ /**
36236
+ * Registers a listener for successful sends.
36237
+ *
36238
+ * @param listener - Callback receiving send complete data
36239
+ * @returns this for chaining
36240
+ */
36241
+ onSendComplete(listener) {
36242
+ return this.on("SEND_COMPLETE", listener);
36243
+ }
36244
+ /**
36245
+ * Registers a listener for failed sends.
36246
+ *
36247
+ * @param listener - Callback receiving send failed data
36248
+ * @returns this for chaining
36249
+ */
36250
+ onSendFailed(listener) {
36251
+ return this.on("SEND_FAILED", listener);
36252
+ }
36253
+ /**
36254
+ * Registers a listener for health state changes.
36255
+ *
36256
+ * @param listener - Callback receiving health change data
36257
+ * @returns this for chaining
36258
+ */
36259
+ onHealthChanged(listener) {
36260
+ return this.on("HEALTH_CHANGED", listener);
36261
+ }
36262
+ /**
36263
+ * Registers a listener for bucket flushes (bucket mode only).
36264
+ *
36265
+ * @param listener - Callback receiving bucket flush data
36266
+ * @returns this for chaining
36267
+ */
36268
+ onBucketFlushed(listener) {
36269
+ return this.on("BUCKET_FLUSHED", listener);
36270
+ }
36271
+ // -----------------------------------------------------------------------
36272
+ // Single/Multiple mode: queue-based dispatch
36273
+ // -----------------------------------------------------------------------
36274
+ /** Enqueues a send and dispatches immediately if capacity allows. */
36275
+ enqueueSend(files, timeoutMs, maxRetries) {
36276
+ return new Promise((resolve) => {
36277
+ const totalQueued = this.queue.length + this.currentBucket.length;
36278
+ if (totalQueued >= this.maxQueueLength) {
36279
+ resolve(err(new Error("DicomSender: queue full")));
36280
+ return;
36281
+ }
36282
+ const entry = { files, timeoutMs, maxRetries, resolve };
36283
+ if (this.activeAssociations < this.effectiveMaxAssociations) {
36284
+ void this.executeEntry(entry);
36285
+ } else {
36286
+ this.queue.push(entry);
36287
+ }
36288
+ });
36289
+ }
36290
+ /** Drains queued entries up to available capacity. */
36291
+ drainQueue() {
36292
+ while (this.queue.length > 0 && this.activeAssociations < this.effectiveMaxAssociations) {
36293
+ const entry = this.queue.shift();
36294
+ if (entry === void 0) break;
36295
+ void this.executeEntry(entry);
36296
+ }
36297
+ }
36298
+ // -----------------------------------------------------------------------
36299
+ // Bucket mode: accumulate-then-flush
36300
+ // -----------------------------------------------------------------------
36301
+ /** Adds files to the current bucket and triggers flush if full. */
36302
+ enqueueBucket(files, timeoutMs, maxRetries) {
36303
+ return new Promise((resolve) => {
36304
+ const totalQueued = this.queue.length + this.currentBucket.length;
36305
+ if (totalQueued >= this.maxQueueLength) {
36306
+ resolve(err(new Error("DicomSender: queue full")));
36307
+ return;
36308
+ }
36309
+ this.currentBucket.push({ files, resolve, timeoutMs, maxRetries });
36310
+ const totalFiles = this.countBucketFiles();
36311
+ if (totalFiles >= this.maxBucketSize) {
36312
+ this.clearBucketTimer();
36313
+ void this.flushBucketInternal("maxSize");
36314
+ } else {
36315
+ this.resetBucketTimer();
36316
+ }
36317
+ });
36318
+ }
36319
+ /** Counts total files in the current bucket. */
36320
+ countBucketFiles() {
36321
+ let count = 0;
36322
+ for (let i = 0; i < this.currentBucket.length; i++) {
36323
+ count += this.currentBucket[i].files.length;
36324
+ }
36325
+ return count;
36326
+ }
36327
+ /** Flushes the current bucket: combines all files, dispatches as one send. */
36328
+ flushBucketInternal(reason) {
36329
+ if (this.currentBucket.length === 0) return;
36330
+ const entries = [...this.currentBucket];
36331
+ this.currentBucket = [];
36332
+ const allFiles = [];
36333
+ for (let i = 0; i < entries.length; i++) {
36334
+ for (let j = 0; j < entries[i].files.length; j++) {
36335
+ allFiles.push(entries[i].files[j]);
36336
+ }
36337
+ }
36338
+ let timeoutMs = 0;
36339
+ let maxRetries = 0;
36340
+ for (let i = 0; i < entries.length; i++) {
36341
+ if (entries[i].timeoutMs > timeoutMs) timeoutMs = entries[i].timeoutMs;
36342
+ if (entries[i].maxRetries > maxRetries) maxRetries = entries[i].maxRetries;
36343
+ }
36344
+ this.emit("BUCKET_FLUSHED", { fileCount: allFiles.length, reason });
36345
+ const bucketEntry = {
36346
+ files: allFiles,
36347
+ timeoutMs,
36348
+ maxRetries,
36349
+ resolve: (result) => {
36350
+ for (let i = 0; i < entries.length; i++) {
36351
+ entries[i].resolve(result);
36352
+ }
36353
+ }
36354
+ };
36355
+ if (this.activeAssociations < this.effectiveMaxAssociations) {
36356
+ void this.executeEntry(bucketEntry);
36357
+ } else {
36358
+ this.queue.push(bucketEntry);
36359
+ }
36360
+ }
36361
+ /** Resets the bucket flush timer. */
36362
+ resetBucketTimer() {
36363
+ this.clearBucketTimer();
36364
+ this.bucketTimer = setTimeout(() => {
36365
+ this.bucketTimer = void 0;
36366
+ void this.flushBucketInternal("timer");
36367
+ }, this.bucketFlushMs);
36368
+ }
36369
+ /** Clears the bucket flush timer. */
36370
+ clearBucketTimer() {
36371
+ if (this.bucketTimer !== void 0) {
36372
+ clearTimeout(this.bucketTimer);
36373
+ this.bucketTimer = void 0;
36374
+ }
36375
+ }
36376
+ // -----------------------------------------------------------------------
36377
+ // Core send execution with retry
36378
+ // -----------------------------------------------------------------------
36379
+ /** Executes a single queue entry: calls storescu with retry. */
36380
+ async executeEntry(entry) {
36381
+ this.activeAssociations++;
36382
+ const startMs = Date.now();
36383
+ const maxAttempts = entry.maxRetries + 1;
36384
+ const lastError = await this.attemptSend(entry, maxAttempts, startMs);
36385
+ if (lastError === void 0) return;
36386
+ this.activeAssociations--;
36387
+ this.recordFailure();
36388
+ this.emit("SEND_FAILED", { files: entry.files, error: lastError, attempts: maxAttempts });
36389
+ entry.resolve(err(lastError));
36390
+ this.drainQueue();
36391
+ }
36392
+ /** Attempts storescu up to maxAttempts times. Returns undefined on success, or the last error. */
36393
+ async attemptSend(entry, maxAttempts, startMs) {
36394
+ let lastError;
36395
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
36396
+ if (this.isStopped) {
36397
+ this.activeAssociations--;
36398
+ entry.resolve(err(new Error("DicomSender: sender stopped")));
36399
+ return void 0;
36400
+ }
36401
+ const result = await this.callStorescu(entry);
36402
+ if (result.ok) {
36403
+ this.handleSendSuccess(entry, startMs);
36404
+ return void 0;
36405
+ }
36406
+ lastError = result.error;
36407
+ if (attempt < maxAttempts - 1) {
36408
+ await delay2(this.retryDelayMs * (attempt + 1));
36409
+ }
36410
+ }
36411
+ return lastError ?? new Error("DicomSender: send failed");
36412
+ }
36413
+ /** Calls storescu with the configured options. */
36414
+ callStorescu(entry) {
36415
+ return storescu({
36416
+ host: this.options.host,
36417
+ port: this.options.port,
36418
+ files: [...entry.files],
36419
+ calledAETitle: this.options.calledAETitle,
36420
+ callingAETitle: this.options.callingAETitle,
36421
+ proposedTransferSyntax: this.options.proposedTransferSyntax,
36422
+ timeoutMs: entry.timeoutMs,
36423
+ signal: this.options.signal
36424
+ });
36425
+ }
36426
+ /** Handles a successful send: updates state, emits event, resolves promise. */
36427
+ handleSendSuccess(entry, startMs) {
36428
+ this.activeAssociations--;
36429
+ const durationMs = Date.now() - startMs;
36430
+ this.recordSuccess();
36431
+ this.emit("SEND_COMPLETE", { files: entry.files, fileCount: entry.files.length, durationMs });
36432
+ entry.resolve(ok({ files: entry.files, fileCount: entry.files.length, durationMs }));
36433
+ this.drainQueue();
36434
+ }
36435
+ // -----------------------------------------------------------------------
36436
+ // Backpressure state machine
36437
+ // -----------------------------------------------------------------------
36438
+ /** Records a successful send and adjusts health upward if needed. */
36439
+ recordSuccess() {
36440
+ this.consecutiveFailures = 0;
36441
+ this.consecutiveSuccesses++;
36442
+ if (this.health === SenderHealth.HEALTHY) return;
36443
+ if (this.consecutiveSuccesses >= RECOVERY_THRESHOLD) {
36444
+ this.consecutiveSuccesses = 0;
36445
+ const previousHealth = this.health;
36446
+ if (this.health === SenderHealth.DOWN) {
36447
+ this.health = SenderHealth.DEGRADED;
36448
+ } else {
36449
+ this.effectiveMaxAssociations = Math.min(this.effectiveMaxAssociations * 2, this.configuredMaxAssociations);
36450
+ if (this.effectiveMaxAssociations >= this.configuredMaxAssociations) {
36451
+ this.health = SenderHealth.HEALTHY;
36452
+ }
36453
+ }
36454
+ this.emitHealthChanged(previousHealth);
36455
+ this.drainQueue();
36456
+ }
36457
+ }
36458
+ /** Records a failed send and adjusts health downward if needed. */
36459
+ recordFailure() {
36460
+ this.consecutiveSuccesses = 0;
36461
+ this.consecutiveFailures++;
36462
+ const previousHealth = this.health;
36463
+ if (this.consecutiveFailures >= DOWN_THRESHOLD) {
36464
+ if (this.health !== SenderHealth.DOWN) {
36465
+ this.health = SenderHealth.DOWN;
36466
+ this.effectiveMaxAssociations = 1;
36467
+ this.emitHealthChanged(previousHealth);
36468
+ }
36469
+ } else if (this.consecutiveFailures >= DEGRADE_THRESHOLD && this.consecutiveFailures % DEGRADE_THRESHOLD === 0) {
36470
+ if (this.health === SenderHealth.HEALTHY) {
36471
+ this.health = SenderHealth.DEGRADED;
36472
+ this.effectiveMaxAssociations = Math.max(1, Math.floor(this.configuredMaxAssociations / 2));
36473
+ this.emitHealthChanged(previousHealth);
36474
+ } else if (this.health === SenderHealth.DEGRADED) {
36475
+ const newMax = Math.max(1, Math.floor(this.effectiveMaxAssociations / 2));
36476
+ if (newMax !== this.effectiveMaxAssociations) {
36477
+ this.effectiveMaxAssociations = newMax;
36478
+ this.emitHealthChanged(previousHealth);
36479
+ }
36480
+ }
36481
+ }
36482
+ }
36483
+ /** Emits a HEALTH_CHANGED event. */
36484
+ emitHealthChanged(previousHealth) {
36485
+ this.emit("HEALTH_CHANGED", {
36486
+ previousHealth,
36487
+ newHealth: this.health,
36488
+ effectiveMaxAssociations: this.effectiveMaxAssociations,
36489
+ consecutiveFailures: this.consecutiveFailures
36490
+ });
36491
+ }
36492
+ // -----------------------------------------------------------------------
36493
+ // Lifecycle helpers
36494
+ // -----------------------------------------------------------------------
36495
+ /** Rejects all queued entries with the given message. */
36496
+ rejectQueue(message) {
36497
+ while (this.queue.length > 0) {
36498
+ const entry = this.queue.shift();
36499
+ if (entry === void 0) break;
36500
+ entry.resolve(err(new Error(message)));
36501
+ }
36502
+ }
36503
+ /** Rejects all bucket entries with the given message. */
36504
+ rejectBucket(message) {
36505
+ while (this.currentBucket.length > 0) {
36506
+ const entry = this.currentBucket.shift();
36507
+ if (entry === void 0) break;
36508
+ entry.resolve(err(new Error(message)));
36509
+ }
36510
+ }
36511
+ /** Waits for all active associations to complete. */
36512
+ waitForActive() {
36513
+ if (this.activeAssociations === 0) return Promise.resolve();
36514
+ return new Promise((resolve) => {
36515
+ const check = () => {
36516
+ if (this.activeAssociations === 0) {
36517
+ resolve();
36518
+ } else {
36519
+ setTimeout(check, 50);
36520
+ }
36521
+ };
36522
+ check();
36523
+ });
36524
+ }
36525
+ // -----------------------------------------------------------------------
36526
+ // Abort signal
36527
+ // -----------------------------------------------------------------------
36528
+ /** Wires an AbortSignal to stop the sender. */
36529
+ wireAbortSignal(signal) {
36530
+ if (signal.aborted) {
36531
+ void this.stop();
36532
+ return;
36533
+ }
36534
+ this.abortHandler = () => {
36535
+ void this.stop();
36536
+ };
36537
+ signal.addEventListener("abort", this.abortHandler, { once: true });
36538
+ }
36539
+ };
36540
+ function delay2(ms) {
36541
+ return new Promise((resolve) => {
36542
+ setTimeout(resolve, ms);
36543
+ });
36544
+ }
36545
+
36020
36546
  // src/pacs/types.ts
36021
36547
  var QueryLevel = {
36022
36548
  STUDY: "STUDY",
@@ -36566,7 +37092,7 @@ var DEFAULT_CONFIG = {
36566
37092
  signal: void 0,
36567
37093
  onRetry: void 0
36568
37094
  };
36569
- function resolveConfig(opts) {
37095
+ function resolveConfig2(opts) {
36570
37096
  if (!opts) return DEFAULT_CONFIG;
36571
37097
  return {
36572
37098
  ...DEFAULT_CONFIG,
@@ -36611,7 +37137,7 @@ function shouldBreakAfterFailure(attempt, lastError, config) {
36611
37137
  return false;
36612
37138
  }
36613
37139
  async function retry(operation, options) {
36614
- const config = resolveConfig(options);
37140
+ const config = resolveConfig2(options);
36615
37141
  let lastResult = err(new Error("No attempts executed"));
36616
37142
  for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
36617
37143
  lastResult = await operation();
@@ -36659,6 +37185,7 @@ exports.DcmtkProcess = DcmtkProcess;
36659
37185
  exports.DicomDataset = DicomDataset;
36660
37186
  exports.DicomInstance = DicomInstance;
36661
37187
  exports.DicomReceiver = DicomReceiver;
37188
+ exports.DicomSender = DicomSender;
36662
37189
  exports.DicomTagPathSchema = DicomTagPathSchema;
36663
37190
  exports.DicomTagSchema = DicomTagSchema;
36664
37191
  exports.FilenameMode = FilenameMode;
@@ -36685,6 +37212,8 @@ exports.RetrieveMode = RetrieveMode;
36685
37212
  exports.SOP_CLASSES = SOP_CLASSES;
36686
37213
  exports.STORESCP_FATAL_EVENTS = STORESCP_FATAL_EVENTS;
36687
37214
  exports.STORESCP_PATTERNS = STORESCP_PATTERNS;
37215
+ exports.SenderHealth = SenderHealth;
37216
+ exports.SenderMode = SenderMode;
36688
37217
  exports.StorageMode = StorageMode;
36689
37218
  exports.StoreSCP = StoreSCP;
36690
37219
  exports.StoreSCPPreset = StoreSCPPreset;