@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.cjs CHANGED
@@ -1557,6 +1557,45 @@ var ChangeSet = class _ChangeSet {
1557
1557
  return result;
1558
1558
  }
1559
1559
  };
1560
+
1561
+ // src/dicom/walkTags.ts
1562
+ var DEFAULT_MAX_DEPTH = 16;
1563
+ function walkTags(data, options) {
1564
+ const ctx = {
1565
+ maxDepth: options?.maxDepth ?? DEFAULT_MAX_DEPTH,
1566
+ vrSet: options?.vrFilter !== void 0 ? new Set(options.vrFilter) : void 0,
1567
+ results: []
1568
+ };
1569
+ walkLevel(data, 0, "", ctx);
1570
+ return ctx.results;
1571
+ }
1572
+ function walkLevel(data, depth, pathPrefix, ctx) {
1573
+ const keys = Object.keys(data);
1574
+ for (let i = 0; i < keys.length; i++) {
1575
+ const tag = keys[i];
1576
+ if (tag === void 0) continue;
1577
+ const element = data[tag];
1578
+ if (element === void 0) continue;
1579
+ const vr = element.vr;
1580
+ const path2 = pathPrefix.length > 0 ? `${pathPrefix}.${tag}` : tag;
1581
+ if (ctx.vrSet === void 0 || ctx.vrSet.has(vr)) {
1582
+ ctx.results.push({ tag, element, vr, depth, path: path2 });
1583
+ }
1584
+ if (vr === "SQ" && element.Value !== void 0 && depth < ctx.maxDepth) {
1585
+ walkSequenceItems(element.Value, depth, path2, ctx);
1586
+ }
1587
+ }
1588
+ }
1589
+ function walkSequenceItems(items, depth, parentPath, ctx) {
1590
+ for (let idx = 0; idx < items.length; idx++) {
1591
+ const item = items[idx];
1592
+ if (typeof item !== "object" || item === null) continue;
1593
+ const itemPath = `${parentPath}[${idx}]`;
1594
+ walkLevel(item, depth + 1, itemPath, ctx);
1595
+ }
1596
+ }
1597
+
1598
+ // src/dicom/DicomDataset.ts
1560
1599
  var HEX_TAG_KEY = /^[0-9A-Fa-f]{8}$/;
1561
1600
  function isDicomJsonElement(value) {
1562
1601
  return typeof value === "object" && value !== null && "vr" in value && typeof value["vr"] === "string";
@@ -1885,6 +1924,15 @@ var DicomDataset = class _DicomDataset {
1885
1924
  const result = collectWildcard(this.data, segments);
1886
1925
  return result.values;
1887
1926
  }
1927
+ /**
1928
+ * Walks all tags in this dataset, optionally filtering by VR and recursing into sequences.
1929
+ *
1930
+ * @param options - Optional VR filter and max depth
1931
+ * @returns A readonly array of all matching tag entries
1932
+ */
1933
+ walkTags(options) {
1934
+ return walkTags(this.data, options);
1935
+ }
1888
1936
  // -----------------------------------------------------------------------
1889
1937
  // Convenience readonly getters
1890
1938
  // -----------------------------------------------------------------------
@@ -2097,15 +2145,32 @@ function convertSequence(attr, element) {
2097
2145
  }
2098
2146
  if (values.length > 0) element.Value = values;
2099
2147
  }
2100
- function convertRegularValue(attr, element) {
2148
+ var NUMERIC_JSON_VRS = /* @__PURE__ */ new Set(["DS", "FL", "FD", "IS", "SL", "SS", "SV", "UL", "US", "UV"]);
2149
+ function unwrapValue(v) {
2150
+ if (typeof v !== "object" || v === null) return v;
2151
+ const obj = v;
2152
+ if ("#text" in obj) return obj["#text"];
2153
+ const keys = Object.keys(obj);
2154
+ if (keys.length === 1 && keys[0] !== void 0 && keys[0].startsWith("@_")) {
2155
+ return obj[keys[0]];
2156
+ }
2157
+ return v;
2158
+ }
2159
+ function coerceNumeric(value) {
2160
+ if (typeof value === "number") return value;
2161
+ if (typeof value !== "string") return value;
2162
+ const trimmed = value.trim();
2163
+ if (trimmed.length === 0) return value;
2164
+ const num = Number(trimmed);
2165
+ return Number.isNaN(num) ? value : num;
2166
+ }
2167
+ function convertRegularValue(attr, element, vr) {
2101
2168
  const valArray = toArray(attr.Value);
2102
2169
  const values = [];
2170
+ const isNumeric = NUMERIC_JSON_VRS.has(vr);
2103
2171
  for (const v of valArray) {
2104
- if (typeof v === "object" && v !== null && "#text" in v) {
2105
- values.push(v["#text"]);
2106
- } else {
2107
- values.push(v);
2108
- }
2172
+ const unwrapped = unwrapValue(v);
2173
+ values.push(isNumeric ? coerceNumeric(unwrapped) : unwrapped);
2109
2174
  }
2110
2175
  if (values.length > 0) element.Value = values;
2111
2176
  }
@@ -2122,7 +2187,7 @@ function convertElement(attr) {
2122
2187
  } else if (element.vr === "SQ" && attr.Item !== void 0) {
2123
2188
  convertSequence(attr, element);
2124
2189
  } else if (attr.Value !== void 0) {
2125
- convertRegularValue(attr, element);
2190
+ convertRegularValue(attr, element, vr);
2126
2191
  }
2127
2192
  return Object.freeze(element);
2128
2193
  }
@@ -2701,8 +2766,9 @@ var DEFAULT_MAX_POOL_SIZE = 10;
2701
2766
  var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2702
2767
  var CONNECTION_RETRY_INTERVAL_MS = 500;
2703
2768
  var MAX_CONNECTION_RETRIES = 200;
2769
+ var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
2704
2770
  var DicomReceiverOptionsSchema = zod.z.object({
2705
- port: zod.z.number().int().min(1).max(65535),
2771
+ port: zod.z.number().int().min(0).max(65535),
2706
2772
  storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
2707
2773
  aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
2708
2774
  minPoolSize: zod.z.number().int().min(1).max(100).optional(),
@@ -2710,6 +2776,9 @@ var DicomReceiverOptionsSchema = zod.z.object({
2710
2776
  connectionTimeoutMs: zod.z.number().int().positive().optional(),
2711
2777
  configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
2712
2778
  configProfile: zod.z.string().min(1).optional(),
2779
+ acseTimeout: zod.z.number().int().positive().optional(),
2780
+ dimseTimeout: zod.z.number().int().positive().optional(),
2781
+ maxPdu: zod.z.number().int().min(4096).max(131072).optional(),
2713
2782
  signal: zod.z.instanceof(AbortSignal).optional()
2714
2783
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
2715
2784
  message: "minPoolSize must be <= maxPoolSize"
@@ -2838,6 +2907,57 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2838
2907
  onAssociationComplete(listener) {
2839
2908
  return this.on("ASSOCIATION_COMPLETE", listener);
2840
2909
  }
2910
+ /**
2911
+ * Registers a listener for new associations (bubbled from workers).
2912
+ *
2913
+ * @param listener - Callback receiving association received data
2914
+ * @returns this for chaining
2915
+ */
2916
+ onAssociationReceived(listener) {
2917
+ return this.on("ASSOCIATION_RECEIVED", listener);
2918
+ }
2919
+ /**
2920
+ * Registers a listener for C-STORE requests (bubbled from workers).
2921
+ *
2922
+ * @param listener - Callback receiving C-STORE request data
2923
+ * @returns this for chaining
2924
+ */
2925
+ onCStoreRequest(listener) {
2926
+ return this.on("C_STORE_REQUEST", listener);
2927
+ }
2928
+ /**
2929
+ * Registers a listener for C-ECHO requests (bubbled from workers).
2930
+ *
2931
+ * @param listener - Callback receiving echo request data
2932
+ * @returns this for chaining
2933
+ */
2934
+ onEchoRequest(listener) {
2935
+ return this.on("ECHO_REQUEST", listener);
2936
+ }
2937
+ /**
2938
+ * Registers a listener for refused associations (bubbled from workers).
2939
+ *
2940
+ * @param listener - Callback receiving refusing association data
2941
+ * @returns this for chaining
2942
+ */
2943
+ onRefusingAssociation(listener) {
2944
+ return this.on("REFUSING_ASSOCIATION", listener);
2945
+ }
2946
+ /**
2947
+ * Routes an external socket to an idle worker.
2948
+ *
2949
+ * The pool must be started via `start()` before calling this method.
2950
+ * Use this when managing your own TCP listener (e.g., protocol router).
2951
+ *
2952
+ * @param socket - An incoming net.Socket to route to a worker
2953
+ */
2954
+ handleSocket(socket) {
2955
+ if (!this.started) {
2956
+ socket.destroy(new Error("DicomReceiver: not started"));
2957
+ return;
2958
+ }
2959
+ void this.handleConnection(socket);
2960
+ }
2841
2961
  /** Current pool status. */
2842
2962
  get poolStatus() {
2843
2963
  let idle = 0;
@@ -2851,8 +2971,11 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2851
2971
  // -----------------------------------------------------------------------
2852
2972
  // TCP proxy
2853
2973
  // -----------------------------------------------------------------------
2854
- /** Starts the TCP proxy on the configured port. */
2974
+ /** Starts the TCP proxy on the configured port. Skips if port is 0. */
2855
2975
  startTcpProxy() {
2976
+ if (this.options.port === 0) {
2977
+ return Promise.resolve(ok(void 0));
2978
+ }
2856
2979
  return new Promise((resolve) => {
2857
2980
  this.tcpServer = net__namespace.createServer((socket) => {
2858
2981
  void this.handleConnection(socket);
@@ -2905,6 +3028,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2905
3028
  worker.associationDir = associationDir;
2906
3029
  worker.files = [];
2907
3030
  worker.fileSizes = [];
3031
+ worker.outputLines = [];
2908
3032
  worker.startAt = Date.now();
2909
3033
  this.pipeConnection(worker, remoteSocket);
2910
3034
  void this.replenishPool();
@@ -2963,6 +3087,19 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2963
3087
  }
2964
3088
  return ok(void 0);
2965
3089
  }
3090
+ /** Creates a Dcmrecv instance with the pool's shared options. */
3091
+ createDcmrecv(port, tempDir) {
3092
+ return Dcmrecv.create({
3093
+ port,
3094
+ aeTitle: this.options.aeTitle ?? "DCMRECV",
3095
+ outputDirectory: tempDir,
3096
+ configFile: this.options.configFile,
3097
+ configProfile: this.options.configProfile,
3098
+ acseTimeout: this.options.acseTimeout,
3099
+ dimseTimeout: this.options.dimseTimeout,
3100
+ maxPdu: this.options.maxPdu
3101
+ });
3102
+ }
2966
3103
  /** Spawns a single Dcmrecv worker with an ephemeral port. */
2967
3104
  async spawnWorker() {
2968
3105
  const portResult = await allocatePort();
@@ -2971,13 +3108,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2971
3108
  const tempDir = path__namespace.join(os__namespace.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
2972
3109
  const mkdirResult = await ensureDirectory(tempDir);
2973
3110
  if (!mkdirResult.ok) return mkdirResult;
2974
- const createResult = Dcmrecv.create({
2975
- port,
2976
- aeTitle: this.options.aeTitle ?? "DCMRECV",
2977
- outputDirectory: tempDir,
2978
- configFile: this.options.configFile,
2979
- configProfile: this.options.configProfile
2980
- });
3111
+ const createResult = this.createDcmrecv(port, tempDir);
2981
3112
  if (!createResult.ok) return createResult;
2982
3113
  const dcmrecv = createResult.value;
2983
3114
  const worker = {
@@ -2989,6 +3120,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2989
3120
  associationDir: void 0,
2990
3121
  files: [],
2991
3122
  fileSizes: [],
3123
+ outputLines: [],
2992
3124
  startAt: void 0,
2993
3125
  remoteSocket: void 0,
2994
3126
  workerSocket: void 0
@@ -3050,10 +3182,15 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3050
3182
  // -----------------------------------------------------------------------
3051
3183
  // Worker event wiring
3052
3184
  // -----------------------------------------------------------------------
3053
- /** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
3185
+ /** Wires all events on a worker: file handling, association lifecycle, output capture. */
3054
3186
  wireWorkerEvents(worker) {
3055
3187
  this.wireFileReceived(worker);
3056
3188
  this.wireAssociationComplete(worker);
3189
+ this.wireAssociationReceived(worker);
3190
+ this.wireCStoreRequest(worker);
3191
+ this.wireEchoRequest(worker);
3192
+ this.wireRefusingAssociation(worker);
3193
+ this.wireOutputCapture(worker);
3057
3194
  }
3058
3195
  /** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
3059
3196
  wireFileReceived(worker) {
@@ -3101,6 +3238,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3101
3238
  const assocId = worker.associationId ?? data.associationId;
3102
3239
  const assocDir = worker.associationDir ?? "";
3103
3240
  const files = [...worker.files];
3241
+ const output = [...worker.outputLines];
3104
3242
  const endAt = Date.now();
3105
3243
  const startAt = worker.startAt ?? endAt;
3106
3244
  const totalBytes = sumArray(worker.fileSizes);
@@ -3118,19 +3256,65 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3118
3256
  totalBytes,
3119
3257
  bytesPerSecond,
3120
3258
  startAt,
3121
- endAt
3259
+ endAt,
3260
+ output
3122
3261
  });
3123
3262
  worker.state = "idle";
3124
3263
  worker.associationId = void 0;
3125
3264
  worker.associationDir = void 0;
3126
3265
  worker.files = [];
3127
3266
  worker.fileSizes = [];
3267
+ worker.outputLines = [];
3128
3268
  worker.startAt = void 0;
3129
3269
  worker.remoteSocket = void 0;
3130
3270
  worker.workerSocket = void 0;
3131
3271
  void this.scaleDown();
3132
3272
  });
3133
3273
  }
3274
+ /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3275
+ wireAssociationReceived(worker) {
3276
+ worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3277
+ this.emit("ASSOCIATION_RECEIVED", {
3278
+ associationId: worker.associationId ?? "",
3279
+ callingAE: data.callingAE,
3280
+ calledAE: data.calledAE,
3281
+ source: data.source
3282
+ });
3283
+ });
3284
+ }
3285
+ /** Bubbles C_STORE_REQUEST from dcmrecv worker. */
3286
+ wireCStoreRequest(worker) {
3287
+ worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
3288
+ this.emit("C_STORE_REQUEST", {
3289
+ associationId: worker.associationId ?? "",
3290
+ raw: data.raw
3291
+ });
3292
+ });
3293
+ }
3294
+ /** Bubbles ECHO_REQUEST from dcmrecv worker. */
3295
+ wireEchoRequest(worker) {
3296
+ worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
3297
+ this.emit("ECHO_REQUEST", {
3298
+ associationId: worker.associationId ?? ""
3299
+ });
3300
+ });
3301
+ }
3302
+ /** Bubbles REFUSING_ASSOCIATION from dcmrecv worker. */
3303
+ wireRefusingAssociation(worker) {
3304
+ worker.dcmrecv.onEvent("REFUSING_ASSOCIATION", (data) => {
3305
+ this.emit("REFUSING_ASSOCIATION", {
3306
+ reason: data.reason
3307
+ });
3308
+ });
3309
+ }
3310
+ /** Captures worker output lines during busy associations. */
3311
+ wireOutputCapture(worker) {
3312
+ worker.dcmrecv.on("line", ({ text }) => {
3313
+ if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
3314
+ worker.outputLines.push(text);
3315
+ }
3316
+ });
3317
+ }
3134
3318
  // -----------------------------------------------------------------------
3135
3319
  // Abort signal
3136
3320
  // -----------------------------------------------------------------------