@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/README.md +13 -11
- package/dist/{DicomInstance-CQEIuF_x.d.ts → DicomInstance-D9plqHp5.d.ts} +1 -1
- package/dist/{DicomInstance-By9zd7GM.d.cts → DicomInstance-DNHPkkzl.d.cts} +1 -1
- package/dist/dicom.cjs.map +1 -1
- package/dist/dicom.d.cts +2 -2
- package/dist/dicom.d.ts +2 -2
- package/dist/dicom.js.map +1 -1
- package/dist/index.cjs +533 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +319 -6
- package/dist/index.d.ts +319 -6
- package/dist/index.js +531 -5
- package/dist/index.js.map +1 -1
- package/dist/servers.cjs +1 -1
- package/dist/servers.cjs.map +1 -1
- package/dist/servers.d.cts +1 -1
- package/dist/servers.d.ts +1 -1
- package/dist/servers.js +1 -1
- package/dist/servers.js.map +1 -1
- package/dist/tools.cjs +1 -1
- package/dist/tools.cjs.map +1 -1
- package/dist/tools.js +1 -1
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
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 = /^[
|
|
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
|
|
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
|
|
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 =
|
|
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;
|