@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/servers.js CHANGED
@@ -1531,6 +1531,45 @@ var ChangeSet = class _ChangeSet {
1531
1531
  return result;
1532
1532
  }
1533
1533
  };
1534
+
1535
+ // src/dicom/walkTags.ts
1536
+ var DEFAULT_MAX_DEPTH = 16;
1537
+ function walkTags(data, options) {
1538
+ const ctx = {
1539
+ maxDepth: options?.maxDepth ?? DEFAULT_MAX_DEPTH,
1540
+ vrSet: options?.vrFilter !== void 0 ? new Set(options.vrFilter) : void 0,
1541
+ results: []
1542
+ };
1543
+ walkLevel(data, 0, "", ctx);
1544
+ return ctx.results;
1545
+ }
1546
+ function walkLevel(data, depth, pathPrefix, ctx) {
1547
+ const keys = Object.keys(data);
1548
+ for (let i = 0; i < keys.length; i++) {
1549
+ const tag = keys[i];
1550
+ if (tag === void 0) continue;
1551
+ const element = data[tag];
1552
+ if (element === void 0) continue;
1553
+ const vr = element.vr;
1554
+ const path2 = pathPrefix.length > 0 ? `${pathPrefix}.${tag}` : tag;
1555
+ if (ctx.vrSet === void 0 || ctx.vrSet.has(vr)) {
1556
+ ctx.results.push({ tag, element, vr, depth, path: path2 });
1557
+ }
1558
+ if (vr === "SQ" && element.Value !== void 0 && depth < ctx.maxDepth) {
1559
+ walkSequenceItems(element.Value, depth, path2, ctx);
1560
+ }
1561
+ }
1562
+ }
1563
+ function walkSequenceItems(items, depth, parentPath, ctx) {
1564
+ for (let idx = 0; idx < items.length; idx++) {
1565
+ const item = items[idx];
1566
+ if (typeof item !== "object" || item === null) continue;
1567
+ const itemPath = `${parentPath}[${idx}]`;
1568
+ walkLevel(item, depth + 1, itemPath, ctx);
1569
+ }
1570
+ }
1571
+
1572
+ // src/dicom/DicomDataset.ts
1534
1573
  var HEX_TAG_KEY = /^[0-9A-Fa-f]{8}$/;
1535
1574
  function isDicomJsonElement(value) {
1536
1575
  return typeof value === "object" && value !== null && "vr" in value && typeof value["vr"] === "string";
@@ -1859,6 +1898,15 @@ var DicomDataset = class _DicomDataset {
1859
1898
  const result = collectWildcard(this.data, segments);
1860
1899
  return result.values;
1861
1900
  }
1901
+ /**
1902
+ * Walks all tags in this dataset, optionally filtering by VR and recursing into sequences.
1903
+ *
1904
+ * @param options - Optional VR filter and max depth
1905
+ * @returns A readonly array of all matching tag entries
1906
+ */
1907
+ walkTags(options) {
1908
+ return walkTags(this.data, options);
1909
+ }
1862
1910
  // -----------------------------------------------------------------------
1863
1911
  // Convenience readonly getters
1864
1912
  // -----------------------------------------------------------------------
@@ -2071,15 +2119,32 @@ function convertSequence(attr, element) {
2071
2119
  }
2072
2120
  if (values.length > 0) element.Value = values;
2073
2121
  }
2074
- function convertRegularValue(attr, element) {
2122
+ var NUMERIC_JSON_VRS = /* @__PURE__ */ new Set(["DS", "FL", "FD", "IS", "SL", "SS", "SV", "UL", "US", "UV"]);
2123
+ function unwrapValue(v) {
2124
+ if (typeof v !== "object" || v === null) return v;
2125
+ const obj = v;
2126
+ if ("#text" in obj) return obj["#text"];
2127
+ const keys = Object.keys(obj);
2128
+ if (keys.length === 1 && keys[0] !== void 0 && keys[0].startsWith("@_")) {
2129
+ return obj[keys[0]];
2130
+ }
2131
+ return v;
2132
+ }
2133
+ function coerceNumeric(value) {
2134
+ if (typeof value === "number") return value;
2135
+ if (typeof value !== "string") return value;
2136
+ const trimmed = value.trim();
2137
+ if (trimmed.length === 0) return value;
2138
+ const num = Number(trimmed);
2139
+ return Number.isNaN(num) ? value : num;
2140
+ }
2141
+ function convertRegularValue(attr, element, vr) {
2075
2142
  const valArray = toArray(attr.Value);
2076
2143
  const values = [];
2144
+ const isNumeric = NUMERIC_JSON_VRS.has(vr);
2077
2145
  for (const v of valArray) {
2078
- if (typeof v === "object" && v !== null && "#text" in v) {
2079
- values.push(v["#text"]);
2080
- } else {
2081
- values.push(v);
2082
- }
2146
+ const unwrapped = unwrapValue(v);
2147
+ values.push(isNumeric ? coerceNumeric(unwrapped) : unwrapped);
2083
2148
  }
2084
2149
  if (values.length > 0) element.Value = values;
2085
2150
  }
@@ -2096,7 +2161,7 @@ function convertElement(attr) {
2096
2161
  } else if (element.vr === "SQ" && attr.Item !== void 0) {
2097
2162
  convertSequence(attr, element);
2098
2163
  } else if (attr.Value !== void 0) {
2099
- convertRegularValue(attr, element);
2164
+ convertRegularValue(attr, element, vr);
2100
2165
  }
2101
2166
  return Object.freeze(element);
2102
2167
  }
@@ -2675,8 +2740,9 @@ var DEFAULT_MAX_POOL_SIZE = 10;
2675
2740
  var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2676
2741
  var CONNECTION_RETRY_INTERVAL_MS = 500;
2677
2742
  var MAX_CONNECTION_RETRIES = 200;
2743
+ var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
2678
2744
  var DicomReceiverOptionsSchema = z.object({
2679
- port: z.number().int().min(1).max(65535),
2745
+ port: z.number().int().min(0).max(65535),
2680
2746
  storageDir: z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
2681
2747
  aeTitle: z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
2682
2748
  minPoolSize: z.number().int().min(1).max(100).optional(),
@@ -2684,6 +2750,9 @@ var DicomReceiverOptionsSchema = z.object({
2684
2750
  connectionTimeoutMs: z.number().int().positive().optional(),
2685
2751
  configFile: z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
2686
2752
  configProfile: z.string().min(1).optional(),
2753
+ acseTimeout: z.number().int().positive().optional(),
2754
+ dimseTimeout: z.number().int().positive().optional(),
2755
+ maxPdu: z.number().int().min(4096).max(131072).optional(),
2687
2756
  signal: z.instanceof(AbortSignal).optional()
2688
2757
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
2689
2758
  message: "minPoolSize must be <= maxPoolSize"
@@ -2812,6 +2881,57 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2812
2881
  onAssociationComplete(listener) {
2813
2882
  return this.on("ASSOCIATION_COMPLETE", listener);
2814
2883
  }
2884
+ /**
2885
+ * Registers a listener for new associations (bubbled from workers).
2886
+ *
2887
+ * @param listener - Callback receiving association received data
2888
+ * @returns this for chaining
2889
+ */
2890
+ onAssociationReceived(listener) {
2891
+ return this.on("ASSOCIATION_RECEIVED", listener);
2892
+ }
2893
+ /**
2894
+ * Registers a listener for C-STORE requests (bubbled from workers).
2895
+ *
2896
+ * @param listener - Callback receiving C-STORE request data
2897
+ * @returns this for chaining
2898
+ */
2899
+ onCStoreRequest(listener) {
2900
+ return this.on("C_STORE_REQUEST", listener);
2901
+ }
2902
+ /**
2903
+ * Registers a listener for C-ECHO requests (bubbled from workers).
2904
+ *
2905
+ * @param listener - Callback receiving echo request data
2906
+ * @returns this for chaining
2907
+ */
2908
+ onEchoRequest(listener) {
2909
+ return this.on("ECHO_REQUEST", listener);
2910
+ }
2911
+ /**
2912
+ * Registers a listener for refused associations (bubbled from workers).
2913
+ *
2914
+ * @param listener - Callback receiving refusing association data
2915
+ * @returns this for chaining
2916
+ */
2917
+ onRefusingAssociation(listener) {
2918
+ return this.on("REFUSING_ASSOCIATION", listener);
2919
+ }
2920
+ /**
2921
+ * Routes an external socket to an idle worker.
2922
+ *
2923
+ * The pool must be started via `start()` before calling this method.
2924
+ * Use this when managing your own TCP listener (e.g., protocol router).
2925
+ *
2926
+ * @param socket - An incoming net.Socket to route to a worker
2927
+ */
2928
+ handleSocket(socket) {
2929
+ if (!this.started) {
2930
+ socket.destroy(new Error("DicomReceiver: not started"));
2931
+ return;
2932
+ }
2933
+ void this.handleConnection(socket);
2934
+ }
2815
2935
  /** Current pool status. */
2816
2936
  get poolStatus() {
2817
2937
  let idle = 0;
@@ -2825,8 +2945,11 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2825
2945
  // -----------------------------------------------------------------------
2826
2946
  // TCP proxy
2827
2947
  // -----------------------------------------------------------------------
2828
- /** Starts the TCP proxy on the configured port. */
2948
+ /** Starts the TCP proxy on the configured port. Skips if port is 0. */
2829
2949
  startTcpProxy() {
2950
+ if (this.options.port === 0) {
2951
+ return Promise.resolve(ok(void 0));
2952
+ }
2830
2953
  return new Promise((resolve) => {
2831
2954
  this.tcpServer = net.createServer((socket) => {
2832
2955
  void this.handleConnection(socket);
@@ -2879,6 +3002,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2879
3002
  worker.associationDir = associationDir;
2880
3003
  worker.files = [];
2881
3004
  worker.fileSizes = [];
3005
+ worker.outputLines = [];
2882
3006
  worker.startAt = Date.now();
2883
3007
  this.pipeConnection(worker, remoteSocket);
2884
3008
  void this.replenishPool();
@@ -2937,6 +3061,19 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2937
3061
  }
2938
3062
  return ok(void 0);
2939
3063
  }
3064
+ /** Creates a Dcmrecv instance with the pool's shared options. */
3065
+ createDcmrecv(port, tempDir) {
3066
+ return Dcmrecv.create({
3067
+ port,
3068
+ aeTitle: this.options.aeTitle ?? "DCMRECV",
3069
+ outputDirectory: tempDir,
3070
+ configFile: this.options.configFile,
3071
+ configProfile: this.options.configProfile,
3072
+ acseTimeout: this.options.acseTimeout,
3073
+ dimseTimeout: this.options.dimseTimeout,
3074
+ maxPdu: this.options.maxPdu
3075
+ });
3076
+ }
2940
3077
  /** Spawns a single Dcmrecv worker with an ephemeral port. */
2941
3078
  async spawnWorker() {
2942
3079
  const portResult = await allocatePort();
@@ -2945,13 +3082,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2945
3082
  const tempDir = path.join(os.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
2946
3083
  const mkdirResult = await ensureDirectory(tempDir);
2947
3084
  if (!mkdirResult.ok) return mkdirResult;
2948
- const createResult = Dcmrecv.create({
2949
- port,
2950
- aeTitle: this.options.aeTitle ?? "DCMRECV",
2951
- outputDirectory: tempDir,
2952
- configFile: this.options.configFile,
2953
- configProfile: this.options.configProfile
2954
- });
3085
+ const createResult = this.createDcmrecv(port, tempDir);
2955
3086
  if (!createResult.ok) return createResult;
2956
3087
  const dcmrecv = createResult.value;
2957
3088
  const worker = {
@@ -2963,6 +3094,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2963
3094
  associationDir: void 0,
2964
3095
  files: [],
2965
3096
  fileSizes: [],
3097
+ outputLines: [],
2966
3098
  startAt: void 0,
2967
3099
  remoteSocket: void 0,
2968
3100
  workerSocket: void 0
@@ -3024,10 +3156,15 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3024
3156
  // -----------------------------------------------------------------------
3025
3157
  // Worker event wiring
3026
3158
  // -----------------------------------------------------------------------
3027
- /** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
3159
+ /** Wires all events on a worker: file handling, association lifecycle, output capture. */
3028
3160
  wireWorkerEvents(worker) {
3029
3161
  this.wireFileReceived(worker);
3030
3162
  this.wireAssociationComplete(worker);
3163
+ this.wireAssociationReceived(worker);
3164
+ this.wireCStoreRequest(worker);
3165
+ this.wireEchoRequest(worker);
3166
+ this.wireRefusingAssociation(worker);
3167
+ this.wireOutputCapture(worker);
3031
3168
  }
3032
3169
  /** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
3033
3170
  wireFileReceived(worker) {
@@ -3075,6 +3212,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3075
3212
  const assocId = worker.associationId ?? data.associationId;
3076
3213
  const assocDir = worker.associationDir ?? "";
3077
3214
  const files = [...worker.files];
3215
+ const output = [...worker.outputLines];
3078
3216
  const endAt = Date.now();
3079
3217
  const startAt = worker.startAt ?? endAt;
3080
3218
  const totalBytes = sumArray(worker.fileSizes);
@@ -3092,19 +3230,65 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3092
3230
  totalBytes,
3093
3231
  bytesPerSecond,
3094
3232
  startAt,
3095
- endAt
3233
+ endAt,
3234
+ output
3096
3235
  });
3097
3236
  worker.state = "idle";
3098
3237
  worker.associationId = void 0;
3099
3238
  worker.associationDir = void 0;
3100
3239
  worker.files = [];
3101
3240
  worker.fileSizes = [];
3241
+ worker.outputLines = [];
3102
3242
  worker.startAt = void 0;
3103
3243
  worker.remoteSocket = void 0;
3104
3244
  worker.workerSocket = void 0;
3105
3245
  void this.scaleDown();
3106
3246
  });
3107
3247
  }
3248
+ /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3249
+ wireAssociationReceived(worker) {
3250
+ worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3251
+ this.emit("ASSOCIATION_RECEIVED", {
3252
+ associationId: worker.associationId ?? "",
3253
+ callingAE: data.callingAE,
3254
+ calledAE: data.calledAE,
3255
+ source: data.source
3256
+ });
3257
+ });
3258
+ }
3259
+ /** Bubbles C_STORE_REQUEST from dcmrecv worker. */
3260
+ wireCStoreRequest(worker) {
3261
+ worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
3262
+ this.emit("C_STORE_REQUEST", {
3263
+ associationId: worker.associationId ?? "",
3264
+ raw: data.raw
3265
+ });
3266
+ });
3267
+ }
3268
+ /** Bubbles ECHO_REQUEST from dcmrecv worker. */
3269
+ wireEchoRequest(worker) {
3270
+ worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
3271
+ this.emit("ECHO_REQUEST", {
3272
+ associationId: worker.associationId ?? ""
3273
+ });
3274
+ });
3275
+ }
3276
+ /** Bubbles REFUSING_ASSOCIATION from dcmrecv worker. */
3277
+ wireRefusingAssociation(worker) {
3278
+ worker.dcmrecv.onEvent("REFUSING_ASSOCIATION", (data) => {
3279
+ this.emit("REFUSING_ASSOCIATION", {
3280
+ reason: data.reason
3281
+ });
3282
+ });
3283
+ }
3284
+ /** Captures worker output lines during busy associations. */
3285
+ wireOutputCapture(worker) {
3286
+ worker.dcmrecv.on("line", ({ text }) => {
3287
+ if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
3288
+ worker.outputLines.push(text);
3289
+ }
3290
+ });
3291
+ }
3108
3292
  // -----------------------------------------------------------------------
3109
3293
  // Abort signal
3110
3294
  // -----------------------------------------------------------------------