@ubercode/dcmtk 0.15.0 → 0.16.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/dicom.cjs +10 -4
- package/dist/dicom.cjs.map +1 -1
- package/dist/dicom.js +10 -4
- package/dist/dicom.js.map +1 -1
- package/dist/{index-D7nnTY1R.d.ts → index-C6dWOhHS.d.ts} +32 -1
- package/dist/{index-CtqMUrjP.d.cts → index-D1msvaAt.d.cts} +32 -1
- package/dist/index.cjs +148 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +148 -11
- package/dist/index.js.map +1 -1
- package/dist/servers.cjs +142 -10
- 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 +142 -10
- package/dist/servers.js.map +1 -1
- package/dist/tools.cjs +10 -4
- package/dist/tools.cjs.map +1 -1
- package/dist/tools.js +10 -4
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/servers.cjs
CHANGED
|
@@ -2134,8 +2134,14 @@ var KNOWN_VR_CODES = /* @__PURE__ */ new Set([
|
|
|
2134
2134
|
"UT",
|
|
2135
2135
|
"UV"
|
|
2136
2136
|
]);
|
|
2137
|
+
function decodeXmlEntities(value) {
|
|
2138
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
2139
|
+
}
|
|
2140
|
+
function decodeIfString(value) {
|
|
2141
|
+
return typeof value === "string" ? decodeXmlEntities(value) : value;
|
|
2142
|
+
}
|
|
2137
2143
|
function buildPnString(comp) {
|
|
2138
|
-
const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""];
|
|
2144
|
+
const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""].map(decodeXmlEntities);
|
|
2139
2145
|
let last = parts.length - 1;
|
|
2140
2146
|
for (; last >= 0; last--) {
|
|
2141
2147
|
if (parts[last] !== "") break;
|
|
@@ -2174,9 +2180,9 @@ function convertBulkDataURI(attr, element) {
|
|
|
2174
2180
|
const bulkArray = toArray(attr.BulkDataURI);
|
|
2175
2181
|
const firstBulk = bulkArray[0];
|
|
2176
2182
|
if (typeof firstBulk === "object" && firstBulk !== null && "@_uri" in firstBulk) {
|
|
2177
|
-
element.BulkDataURI = safeString(firstBulk["@_uri"]);
|
|
2183
|
+
element.BulkDataURI = decodeXmlEntities(safeString(firstBulk["@_uri"]));
|
|
2178
2184
|
} else {
|
|
2179
|
-
element.BulkDataURI = safeString(firstBulk);
|
|
2185
|
+
element.BulkDataURI = decodeXmlEntities(safeString(firstBulk));
|
|
2180
2186
|
}
|
|
2181
2187
|
}
|
|
2182
2188
|
function convertPNValue(attr, element) {
|
|
@@ -2220,7 +2226,7 @@ function convertRegularValue(attr, element, vr) {
|
|
|
2220
2226
|
const values = [];
|
|
2221
2227
|
const isNumeric = NUMERIC_JSON_VRS.has(vr);
|
|
2222
2228
|
for (const v of valArray) {
|
|
2223
|
-
const unwrapped = unwrapValue(v);
|
|
2229
|
+
const unwrapped = decodeIfString(unwrapValue(v));
|
|
2224
2230
|
values.push(isNumeric ? coerceNumeric(unwrapped) : unwrapped);
|
|
2225
2231
|
}
|
|
2226
2232
|
if (values.length > 0) element.Value = values;
|
|
@@ -2856,6 +2862,7 @@ var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
|
|
|
2856
2862
|
var CONNECTION_RETRY_INTERVAL_MS = 500;
|
|
2857
2863
|
var MAX_CONNECTION_RETRIES = 200;
|
|
2858
2864
|
var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
|
|
2865
|
+
var ABORT_REAP_GRACE_MS = 5e3;
|
|
2859
2866
|
var DicomReceiverOptionsSchema = zod.z.object({
|
|
2860
2867
|
port: zod.z.number().int().min(0).max(65535),
|
|
2861
2868
|
storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
|
|
@@ -2893,6 +2900,9 @@ var Worker = class {
|
|
|
2893
2900
|
__publicField(this, "_instancePending", /* @__PURE__ */ new Set());
|
|
2894
2901
|
__publicField(this, "_instancesReceived", 0);
|
|
2895
2902
|
__publicField(this, "_instanceErrors", 0);
|
|
2903
|
+
__publicField(this, "_finalized", false);
|
|
2904
|
+
__publicField(this, "_associationReceivedEmitted", false);
|
|
2905
|
+
__publicField(this, "_reapScheduled", false);
|
|
2896
2906
|
__publicField(this, "_files", []);
|
|
2897
2907
|
__publicField(this, "_fileSizes", []);
|
|
2898
2908
|
__publicField(this, "_outputLines", []);
|
|
@@ -2933,6 +2943,62 @@ var Worker = class {
|
|
|
2933
2943
|
this._instancePending.clear();
|
|
2934
2944
|
this._instancesReceived = 0;
|
|
2935
2945
|
this._instanceErrors = 0;
|
|
2946
|
+
this._finalized = false;
|
|
2947
|
+
this._associationReceivedEmitted = false;
|
|
2948
|
+
this._reapScheduled = false;
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Claims the single abort-reap timer slot for this association. The socket
|
|
2952
|
+
* `cleanup` handler runs on up to four socket events (remote/worker ×
|
|
2953
|
+
* error/close, plus an explicit setup-race call), so without this guard each
|
|
2954
|
+
* teardown would arm its own redundant grace-period timer. Returns true only
|
|
2955
|
+
* for the first caller; subsequent calls return false and arm nothing.
|
|
2956
|
+
*/
|
|
2957
|
+
claimReapSchedule() {
|
|
2958
|
+
if (this._reapScheduled) return false;
|
|
2959
|
+
this._reapScheduled = true;
|
|
2960
|
+
return true;
|
|
2961
|
+
}
|
|
2962
|
+
/** True once per association: the first caller wins the finalize, others get false. */
|
|
2963
|
+
beginFinalizeOnce() {
|
|
2964
|
+
if (this._finalized) return false;
|
|
2965
|
+
this._finalized = true;
|
|
2966
|
+
return true;
|
|
2967
|
+
}
|
|
2968
|
+
/** Whether finalize handling has already been claimed for the current association. */
|
|
2969
|
+
get finalized() {
|
|
2970
|
+
return this._finalized;
|
|
2971
|
+
}
|
|
2972
|
+
/** Records that ASSOCIATION_RECEIVED was emitted to consumers for this association. */
|
|
2973
|
+
markAssociationReceived() {
|
|
2974
|
+
this._associationReceivedEmitted = true;
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Whether ASSOCIATION_RECEIVED was emitted for the current association —
|
|
2978
|
+
* i.e. the association was handed off to consumers. When false (a connection
|
|
2979
|
+
* that aborted during/before DICOM negotiation), no consumer ever engaged
|
|
2980
|
+
* with the association directory, so the pool owns its cleanup.
|
|
2981
|
+
*/
|
|
2982
|
+
get associationReceived() {
|
|
2983
|
+
return this._associationReceivedEmitted;
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Atomically reserves a just-selected idle worker so concurrent connection
|
|
2987
|
+
* routing cannot hand the same worker to two associations. Must be called
|
|
2988
|
+
* synchronously the instant an idle worker is picked — before any `await` —
|
|
2989
|
+
* because `handleConnection` yields (e.g. on `ensureDirectory`) between
|
|
2990
|
+
* selecting a worker and calling {@link beginAssociation}. Without this,
|
|
2991
|
+
* two concurrent connections both see the worker as idle, both proceed, and
|
|
2992
|
+
* the second `beginAssociation` overwrites the first's context — orphaning
|
|
2993
|
+
* the first association (no terminal event ever fires for it). Released via
|
|
2994
|
+
* {@link release} if association setup fails before `beginAssociation`.
|
|
2995
|
+
*/
|
|
2996
|
+
reserve() {
|
|
2997
|
+
this._state = "busy";
|
|
2998
|
+
}
|
|
2999
|
+
/** Returns a reserved-but-not-yet-begun worker to the idle pool (setup failed). */
|
|
3000
|
+
release() {
|
|
3001
|
+
this._state = "idle";
|
|
2936
3002
|
}
|
|
2937
3003
|
/** Marks the worker as finalizing — still has valid context but cannot accept new connections. */
|
|
2938
3004
|
markFinalizing() {
|
|
@@ -3318,6 +3384,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
|
3318
3384
|
const associationDir = path__namespace.join(this.options.storageDir, associationId);
|
|
3319
3385
|
const mkdirResult = await ensureDirectory(associationDir);
|
|
3320
3386
|
if (!mkdirResult.ok) {
|
|
3387
|
+
worker.release();
|
|
3321
3388
|
remoteSocket.destroy();
|
|
3322
3389
|
this.emit("error", { error: mkdirResult.error });
|
|
3323
3390
|
return;
|
|
@@ -3336,12 +3403,18 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
|
3336
3403
|
/** Finds an idle worker, retrying up to connectionTimeoutMs. */
|
|
3337
3404
|
async findIdleWorker() {
|
|
3338
3405
|
const idle = this.getIdleWorker();
|
|
3339
|
-
if (idle !== void 0)
|
|
3406
|
+
if (idle !== void 0) {
|
|
3407
|
+
idle.reserve();
|
|
3408
|
+
return idle;
|
|
3409
|
+
}
|
|
3340
3410
|
const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
|
|
3341
3411
|
for (let i = 0; i < maxRetries; i++) {
|
|
3342
3412
|
await promises.setTimeout(CONNECTION_RETRY_INTERVAL_MS);
|
|
3343
3413
|
const found = this.getIdleWorker();
|
|
3344
|
-
if (found !== void 0)
|
|
3414
|
+
if (found !== void 0) {
|
|
3415
|
+
found.reserve();
|
|
3416
|
+
return found;
|
|
3417
|
+
}
|
|
3345
3418
|
if (this.stopping) return void 0;
|
|
3346
3419
|
}
|
|
3347
3420
|
return void 0;
|
|
@@ -3357,11 +3430,13 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
|
3357
3430
|
pipeConnection(worker, remoteSocket) {
|
|
3358
3431
|
const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
|
|
3359
3432
|
worker.setSockets(remoteSocket, workerSocket);
|
|
3433
|
+
const ctx = worker.context;
|
|
3360
3434
|
const cleanup = () => {
|
|
3361
3435
|
remoteSocket.unpipe(workerSocket);
|
|
3362
3436
|
workerSocket.unpipe(remoteSocket);
|
|
3363
3437
|
if (!remoteSocket.destroyed) remoteSocket.destroy();
|
|
3364
3438
|
if (!workerSocket.destroyed) workerSocket.destroy();
|
|
3439
|
+
this.scheduleAbortReap(worker, ctx);
|
|
3365
3440
|
};
|
|
3366
3441
|
workerSocket.on("connect", () => {
|
|
3367
3442
|
remoteSocket.pipe(workerSocket);
|
|
@@ -3372,6 +3447,42 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
|
3372
3447
|
workerSocket.on("error", cleanup);
|
|
3373
3448
|
remoteSocket.on("close", cleanup);
|
|
3374
3449
|
workerSocket.on("close", cleanup);
|
|
3450
|
+
if (remoteSocket.destroyed) {
|
|
3451
|
+
cleanup();
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
/**
|
|
3455
|
+
* After a connection's sockets tear down, reaps the association as an
|
|
3456
|
+
* aborted one *only if* dcmrecv never reported completion within
|
|
3457
|
+
* {@link ABORT_REAP_GRACE_MS}. Without this, a peer abort destroys the
|
|
3458
|
+
* sockets but dcmrecv may never emit ASSOCIATION_COMPLETE, so the worker
|
|
3459
|
+
* stays busy forever, ASSOCIATION_FINALIZED never fires (consumers can't
|
|
3460
|
+
* release per-association state), and the association directory is never
|
|
3461
|
+
* removed. The grace delay ensures a normal A-RELEASE — whose socket also
|
|
3462
|
+
* closes — is finalized by its own completion report and never reaped here.
|
|
3463
|
+
*/
|
|
3464
|
+
scheduleAbortReap(worker, ctx) {
|
|
3465
|
+
if (ctx === void 0 || worker.context !== ctx || worker.state !== "busy") return;
|
|
3466
|
+
if (!worker.claimReapSchedule()) return;
|
|
3467
|
+
const timer = setTimeout(() => {
|
|
3468
|
+
if (worker.context !== ctx || worker.state !== "busy" || worker.finalized) return;
|
|
3469
|
+
worker.markFinalizing();
|
|
3470
|
+
const removeDir = !worker.associationReceived;
|
|
3471
|
+
void this.finalizeAssociation(
|
|
3472
|
+
worker,
|
|
3473
|
+
{
|
|
3474
|
+
associationId: ctx.associationId,
|
|
3475
|
+
callingAE: "",
|
|
3476
|
+
calledAE: "",
|
|
3477
|
+
source: "",
|
|
3478
|
+
files: [],
|
|
3479
|
+
durationMs: Date.now() - ctx.startAt,
|
|
3480
|
+
endReason: "abort"
|
|
3481
|
+
},
|
|
3482
|
+
removeDir
|
|
3483
|
+
);
|
|
3484
|
+
}, ABORT_REAP_GRACE_MS);
|
|
3485
|
+
timer.unref();
|
|
3375
3486
|
}
|
|
3376
3487
|
// -----------------------------------------------------------------------
|
|
3377
3488
|
// Worker pool management
|
|
@@ -3570,19 +3681,38 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
|
3570
3681
|
/** Returns worker to idle pool on association complete, emits summary. */
|
|
3571
3682
|
wireAssociationComplete(worker) {
|
|
3572
3683
|
worker.dcmrecv.onAssociationComplete((data) => {
|
|
3684
|
+
if (worker.finalized) return;
|
|
3573
3685
|
worker.markFinalizing();
|
|
3686
|
+
const removeDir = !worker.associationReceived;
|
|
3574
3687
|
setImmediate(() => {
|
|
3575
|
-
void this.finalizeAssociation(worker, data);
|
|
3688
|
+
void this.finalizeAssociation(worker, data, removeDir);
|
|
3576
3689
|
});
|
|
3577
3690
|
});
|
|
3578
3691
|
}
|
|
3579
|
-
/**
|
|
3580
|
-
|
|
3692
|
+
/**
|
|
3693
|
+
* Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits
|
|
3694
|
+
* ASSOCIATION_FINALIZED, returns the worker to idle. Runs at most once per
|
|
3695
|
+
* association via {@link Worker.beginFinalizeOnce}.
|
|
3696
|
+
*
|
|
3697
|
+
* @param removeDir - When true, removes the association directory after
|
|
3698
|
+
* finalizing. Set when no consumer ever engaged with the association
|
|
3699
|
+
* (no ASSOCIATION_RECEIVED was emitted) — e.g. a connection that aborted
|
|
3700
|
+
* during negotiation — so the pool reclaims the directory it created.
|
|
3701
|
+
* When a consumer did engage this stays false: the consumer owns
|
|
3702
|
+
* directory cleanup once it has processed the received files (removing it
|
|
3703
|
+
* here could race in-flight consumer reads of those files).
|
|
3704
|
+
*/
|
|
3705
|
+
async finalizeAssociation(worker, data, removeDir = false) {
|
|
3706
|
+
if (!worker.beginFinalizeOnce()) return;
|
|
3581
3707
|
await worker.drainPendingFiles();
|
|
3582
3708
|
this.emitAssociationComplete(worker, data);
|
|
3583
3709
|
await worker.drainInstancePending();
|
|
3584
3710
|
this.emitAssociationFinalized(worker, data);
|
|
3711
|
+
const associationDir = worker.context?.associationDir;
|
|
3585
3712
|
worker.endAssociation();
|
|
3713
|
+
if (removeDir && associationDir !== void 0) {
|
|
3714
|
+
await removeDirSafe(associationDir);
|
|
3715
|
+
}
|
|
3586
3716
|
void this.scaleDown();
|
|
3587
3717
|
}
|
|
3588
3718
|
/** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
|
|
@@ -3624,12 +3754,14 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
|
3624
3754
|
source: data.source,
|
|
3625
3755
|
files: [...worker.files],
|
|
3626
3756
|
instancesReceived: worker.instancesReceived,
|
|
3627
|
-
instanceErrors: worker.instanceErrors
|
|
3757
|
+
instanceErrors: worker.instanceErrors,
|
|
3758
|
+
endReason: data.endReason
|
|
3628
3759
|
});
|
|
3629
3760
|
}
|
|
3630
3761
|
/** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
|
|
3631
3762
|
wireAssociationReceived(worker) {
|
|
3632
3763
|
worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
|
|
3764
|
+
worker.markAssociationReceived();
|
|
3633
3765
|
this.emit("ASSOCIATION_RECEIVED", {
|
|
3634
3766
|
associationId: worker.context?.associationId ?? "",
|
|
3635
3767
|
callingAE: data.callingAE,
|