@ubercode/dcmtk 0.4.0 → 0.6.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
@@ -30480,6 +30480,45 @@ function segmentsToModifyPath(segments) {
30480
30480
  function segmentsToString(segments) {
30481
30481
  return segmentsToModifyPath(segments);
30482
30482
  }
30483
+
30484
+ // src/dicom/walkTags.ts
30485
+ var DEFAULT_MAX_DEPTH = 16;
30486
+ function walkTags(data, options) {
30487
+ const ctx = {
30488
+ maxDepth: options?.maxDepth ?? DEFAULT_MAX_DEPTH,
30489
+ vrSet: options?.vrFilter !== void 0 ? new Set(options.vrFilter) : void 0,
30490
+ results: []
30491
+ };
30492
+ walkLevel(data, 0, "", ctx);
30493
+ return ctx.results;
30494
+ }
30495
+ function walkLevel(data, depth, pathPrefix, ctx) {
30496
+ const keys = Object.keys(data);
30497
+ for (let i = 0; i < keys.length; i++) {
30498
+ const tag2 = keys[i];
30499
+ if (tag2 === void 0) continue;
30500
+ const element = data[tag2];
30501
+ if (element === void 0) continue;
30502
+ const vr = element.vr;
30503
+ const path2 = pathPrefix.length > 0 ? `${pathPrefix}.${tag2}` : tag2;
30504
+ if (ctx.vrSet === void 0 || ctx.vrSet.has(vr)) {
30505
+ ctx.results.push({ tag: tag2, element, vr, depth, path: path2 });
30506
+ }
30507
+ if (vr === "SQ" && element.Value !== void 0 && depth < ctx.maxDepth) {
30508
+ walkSequenceItems(element.Value, depth, path2, ctx);
30509
+ }
30510
+ }
30511
+ }
30512
+ function walkSequenceItems(items, depth, parentPath, ctx) {
30513
+ for (let idx = 0; idx < items.length; idx++) {
30514
+ const item = items[idx];
30515
+ if (typeof item !== "object" || item === null) continue;
30516
+ const itemPath = `${parentPath}[${idx}]`;
30517
+ walkLevel(item, depth + 1, itemPath, ctx);
30518
+ }
30519
+ }
30520
+
30521
+ // src/dicom/DicomDataset.ts
30483
30522
  var HEX_TAG_KEY = /^[0-9A-Fa-f]{8}$/;
30484
30523
  function isDicomJsonElement(value) {
30485
30524
  return typeof value === "object" && value !== null && "vr" in value && typeof value["vr"] === "string";
@@ -30808,6 +30847,15 @@ var DicomDataset = class _DicomDataset {
30808
30847
  const result = collectWildcard(this.data, segments);
30809
30848
  return result.values;
30810
30849
  }
30850
+ /**
30851
+ * Walks all tags in this dataset, optionally filtering by VR and recursing into sequences.
30852
+ *
30853
+ * @param options - Optional VR filter and max depth
30854
+ * @returns A readonly array of all matching tag entries
30855
+ */
30856
+ walkTags(options) {
30857
+ return walkTags(this.data, options);
30858
+ }
30811
30859
  // -----------------------------------------------------------------------
30812
30860
  // Convenience readonly getters
30813
30861
  // -----------------------------------------------------------------------
@@ -31286,15 +31334,32 @@ function convertSequence(attr, element) {
31286
31334
  }
31287
31335
  if (values.length > 0) element.Value = values;
31288
31336
  }
31289
- function convertRegularValue(attr, element) {
31337
+ var NUMERIC_JSON_VRS = /* @__PURE__ */ new Set(["DS", "FL", "FD", "IS", "SL", "SS", "SV", "UL", "US", "UV"]);
31338
+ function unwrapValue(v) {
31339
+ if (typeof v !== "object" || v === null) return v;
31340
+ const obj = v;
31341
+ if ("#text" in obj) return obj["#text"];
31342
+ const keys = Object.keys(obj);
31343
+ if (keys.length === 1 && keys[0] !== void 0 && keys[0].startsWith("@_")) {
31344
+ return obj[keys[0]];
31345
+ }
31346
+ return v;
31347
+ }
31348
+ function coerceNumeric(value) {
31349
+ if (typeof value === "number") return value;
31350
+ if (typeof value !== "string") return value;
31351
+ const trimmed = value.trim();
31352
+ if (trimmed.length === 0) return value;
31353
+ const num = Number(trimmed);
31354
+ return Number.isNaN(num) ? value : num;
31355
+ }
31356
+ function convertRegularValue(attr, element, vr) {
31290
31357
  const valArray = toArray(attr.Value);
31291
31358
  const values = [];
31359
+ const isNumeric = NUMERIC_JSON_VRS.has(vr);
31292
31360
  for (const v of valArray) {
31293
- if (typeof v === "object" && v !== null && "#text" in v) {
31294
- values.push(v["#text"]);
31295
- } else {
31296
- values.push(v);
31297
- }
31361
+ const unwrapped = unwrapValue(v);
31362
+ values.push(isNumeric ? coerceNumeric(unwrapped) : unwrapped);
31298
31363
  }
31299
31364
  if (values.length > 0) element.Value = values;
31300
31365
  }
@@ -31311,7 +31376,7 @@ function convertElement(attr) {
31311
31376
  } else if (element.vr === "SQ" && attr.Item !== void 0) {
31312
31377
  convertSequence(attr, element);
31313
31378
  } else if (attr.Value !== void 0) {
31314
- convertRegularValue(attr, element);
31379
+ convertRegularValue(attr, element, vr);
31315
31380
  }
31316
31381
  return Object.freeze(element);
31317
31382
  }
@@ -36119,8 +36184,9 @@ var DEFAULT_MAX_POOL_SIZE = 10;
36119
36184
  var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
36120
36185
  var CONNECTION_RETRY_INTERVAL_MS = 500;
36121
36186
  var MAX_CONNECTION_RETRIES = 200;
36187
+ var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
36122
36188
  var DicomReceiverOptionsSchema = zod.z.object({
36123
- port: zod.z.number().int().min(1).max(65535),
36189
+ port: zod.z.number().int().min(0).max(65535),
36124
36190
  storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
36125
36191
  aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
36126
36192
  minPoolSize: zod.z.number().int().min(1).max(100).optional(),
@@ -36128,6 +36194,9 @@ var DicomReceiverOptionsSchema = zod.z.object({
36128
36194
  connectionTimeoutMs: zod.z.number().int().positive().optional(),
36129
36195
  configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
36130
36196
  configProfile: zod.z.string().min(1).optional(),
36197
+ acseTimeout: zod.z.number().int().positive().optional(),
36198
+ dimseTimeout: zod.z.number().int().positive().optional(),
36199
+ maxPdu: zod.z.number().int().min(4096).max(131072).optional(),
36131
36200
  signal: zod.z.instanceof(AbortSignal).optional()
36132
36201
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
36133
36202
  message: "minPoolSize must be <= maxPoolSize"
@@ -36256,6 +36325,57 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36256
36325
  onAssociationComplete(listener) {
36257
36326
  return this.on("ASSOCIATION_COMPLETE", listener);
36258
36327
  }
36328
+ /**
36329
+ * Registers a listener for new associations (bubbled from workers).
36330
+ *
36331
+ * @param listener - Callback receiving association received data
36332
+ * @returns this for chaining
36333
+ */
36334
+ onAssociationReceived(listener) {
36335
+ return this.on("ASSOCIATION_RECEIVED", listener);
36336
+ }
36337
+ /**
36338
+ * Registers a listener for C-STORE requests (bubbled from workers).
36339
+ *
36340
+ * @param listener - Callback receiving C-STORE request data
36341
+ * @returns this for chaining
36342
+ */
36343
+ onCStoreRequest(listener) {
36344
+ return this.on("C_STORE_REQUEST", listener);
36345
+ }
36346
+ /**
36347
+ * Registers a listener for C-ECHO requests (bubbled from workers).
36348
+ *
36349
+ * @param listener - Callback receiving echo request data
36350
+ * @returns this for chaining
36351
+ */
36352
+ onEchoRequest(listener) {
36353
+ return this.on("ECHO_REQUEST", listener);
36354
+ }
36355
+ /**
36356
+ * Registers a listener for refused associations (bubbled from workers).
36357
+ *
36358
+ * @param listener - Callback receiving refusing association data
36359
+ * @returns this for chaining
36360
+ */
36361
+ onRefusingAssociation(listener) {
36362
+ return this.on("REFUSING_ASSOCIATION", listener);
36363
+ }
36364
+ /**
36365
+ * Routes an external socket to an idle worker.
36366
+ *
36367
+ * The pool must be started via `start()` before calling this method.
36368
+ * Use this when managing your own TCP listener (e.g., protocol router).
36369
+ *
36370
+ * @param socket - An incoming net.Socket to route to a worker
36371
+ */
36372
+ handleSocket(socket) {
36373
+ if (!this.started) {
36374
+ socket.destroy(new Error("DicomReceiver: not started"));
36375
+ return;
36376
+ }
36377
+ void this.handleConnection(socket);
36378
+ }
36259
36379
  /** Current pool status. */
36260
36380
  get poolStatus() {
36261
36381
  let idle = 0;
@@ -36269,8 +36389,11 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36269
36389
  // -----------------------------------------------------------------------
36270
36390
  // TCP proxy
36271
36391
  // -----------------------------------------------------------------------
36272
- /** Starts the TCP proxy on the configured port. */
36392
+ /** Starts the TCP proxy on the configured port. Skips if port is 0. */
36273
36393
  startTcpProxy() {
36394
+ if (this.options.port === 0) {
36395
+ return Promise.resolve(ok(void 0));
36396
+ }
36274
36397
  return new Promise((resolve) => {
36275
36398
  this.tcpServer = net__namespace.createServer((socket) => {
36276
36399
  void this.handleConnection(socket);
@@ -36323,6 +36446,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36323
36446
  worker.associationDir = associationDir;
36324
36447
  worker.files = [];
36325
36448
  worker.fileSizes = [];
36449
+ worker.outputLines = [];
36326
36450
  worker.startAt = Date.now();
36327
36451
  this.pipeConnection(worker, remoteSocket);
36328
36452
  void this.replenishPool();
@@ -36381,6 +36505,19 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36381
36505
  }
36382
36506
  return ok(void 0);
36383
36507
  }
36508
+ /** Creates a Dcmrecv instance with the pool's shared options. */
36509
+ createDcmrecv(port, tempDir) {
36510
+ return Dcmrecv.create({
36511
+ port,
36512
+ aeTitle: this.options.aeTitle ?? "DCMRECV",
36513
+ outputDirectory: tempDir,
36514
+ configFile: this.options.configFile,
36515
+ configProfile: this.options.configProfile,
36516
+ acseTimeout: this.options.acseTimeout,
36517
+ dimseTimeout: this.options.dimseTimeout,
36518
+ maxPdu: this.options.maxPdu
36519
+ });
36520
+ }
36384
36521
  /** Spawns a single Dcmrecv worker with an ephemeral port. */
36385
36522
  async spawnWorker() {
36386
36523
  const portResult = await allocatePort();
@@ -36389,13 +36526,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36389
36526
  const tempDir = path__namespace.join(os__namespace.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
36390
36527
  const mkdirResult = await ensureDirectory(tempDir);
36391
36528
  if (!mkdirResult.ok) return mkdirResult;
36392
- const createResult = Dcmrecv.create({
36393
- port,
36394
- aeTitle: this.options.aeTitle ?? "DCMRECV",
36395
- outputDirectory: tempDir,
36396
- configFile: this.options.configFile,
36397
- configProfile: this.options.configProfile
36398
- });
36529
+ const createResult = this.createDcmrecv(port, tempDir);
36399
36530
  if (!createResult.ok) return createResult;
36400
36531
  const dcmrecv = createResult.value;
36401
36532
  const worker = {
@@ -36407,6 +36538,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36407
36538
  associationDir: void 0,
36408
36539
  files: [],
36409
36540
  fileSizes: [],
36541
+ outputLines: [],
36410
36542
  startAt: void 0,
36411
36543
  remoteSocket: void 0,
36412
36544
  workerSocket: void 0
@@ -36468,10 +36600,15 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36468
36600
  // -----------------------------------------------------------------------
36469
36601
  // Worker event wiring
36470
36602
  // -----------------------------------------------------------------------
36471
- /** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
36603
+ /** Wires all events on a worker: file handling, association lifecycle, output capture. */
36472
36604
  wireWorkerEvents(worker) {
36473
36605
  this.wireFileReceived(worker);
36474
36606
  this.wireAssociationComplete(worker);
36607
+ this.wireAssociationReceived(worker);
36608
+ this.wireCStoreRequest(worker);
36609
+ this.wireEchoRequest(worker);
36610
+ this.wireRefusingAssociation(worker);
36611
+ this.wireOutputCapture(worker);
36475
36612
  }
36476
36613
  /** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
36477
36614
  wireFileReceived(worker) {
@@ -36519,6 +36656,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36519
36656
  const assocId = worker.associationId ?? data.associationId;
36520
36657
  const assocDir = worker.associationDir ?? "";
36521
36658
  const files = [...worker.files];
36659
+ const output = [...worker.outputLines];
36522
36660
  const endAt = Date.now();
36523
36661
  const startAt = worker.startAt ?? endAt;
36524
36662
  const totalBytes = sumArray(worker.fileSizes);
@@ -36536,19 +36674,65 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36536
36674
  totalBytes,
36537
36675
  bytesPerSecond,
36538
36676
  startAt,
36539
- endAt
36677
+ endAt,
36678
+ output
36540
36679
  });
36541
36680
  worker.state = "idle";
36542
36681
  worker.associationId = void 0;
36543
36682
  worker.associationDir = void 0;
36544
36683
  worker.files = [];
36545
36684
  worker.fileSizes = [];
36685
+ worker.outputLines = [];
36546
36686
  worker.startAt = void 0;
36547
36687
  worker.remoteSocket = void 0;
36548
36688
  worker.workerSocket = void 0;
36549
36689
  void this.scaleDown();
36550
36690
  });
36551
36691
  }
36692
+ /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
36693
+ wireAssociationReceived(worker) {
36694
+ worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
36695
+ this.emit("ASSOCIATION_RECEIVED", {
36696
+ associationId: worker.associationId ?? "",
36697
+ callingAE: data.callingAE,
36698
+ calledAE: data.calledAE,
36699
+ source: data.source
36700
+ });
36701
+ });
36702
+ }
36703
+ /** Bubbles C_STORE_REQUEST from dcmrecv worker. */
36704
+ wireCStoreRequest(worker) {
36705
+ worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
36706
+ this.emit("C_STORE_REQUEST", {
36707
+ associationId: worker.associationId ?? "",
36708
+ raw: data.raw
36709
+ });
36710
+ });
36711
+ }
36712
+ /** Bubbles ECHO_REQUEST from dcmrecv worker. */
36713
+ wireEchoRequest(worker) {
36714
+ worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
36715
+ this.emit("ECHO_REQUEST", {
36716
+ associationId: worker.associationId ?? ""
36717
+ });
36718
+ });
36719
+ }
36720
+ /** Bubbles REFUSING_ASSOCIATION from dcmrecv worker. */
36721
+ wireRefusingAssociation(worker) {
36722
+ worker.dcmrecv.onEvent("REFUSING_ASSOCIATION", (data) => {
36723
+ this.emit("REFUSING_ASSOCIATION", {
36724
+ reason: data.reason
36725
+ });
36726
+ });
36727
+ }
36728
+ /** Captures worker output lines during busy associations. */
36729
+ wireOutputCapture(worker) {
36730
+ worker.dcmrecv.on("line", ({ text }) => {
36731
+ if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
36732
+ worker.outputLines.push(text);
36733
+ }
36734
+ });
36735
+ }
36552
36736
  // -----------------------------------------------------------------------
36553
36737
  // Abort signal
36554
36738
  // -----------------------------------------------------------------------
@@ -37913,6 +38097,7 @@ exports.storescu = storescu;
37913
38097
  exports.tag = tag;
37914
38098
  exports.tagPathToSegments = tagPathToSegments;
37915
38099
  exports.termscu = termscu;
38100
+ exports.walkTags = walkTags;
37916
38101
  exports.xml2dcm = xml2dcm;
37917
38102
  exports.xml2dsr = xml2dsr;
37918
38103
  exports.xmlToJson = xmlToJson;