@ubercode/dcmtk 0.3.0 → 0.5.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
@@ -2175,19 +2175,28 @@ function repairJson(raw) {
2175
2175
  var Dcm2jsonOptionsSchema = z.object({
2176
2176
  timeoutMs: z.number().int().positive().optional(),
2177
2177
  signal: z.instanceof(AbortSignal).optional(),
2178
- directOnly: z.boolean().optional()
2178
+ directOnly: z.boolean().optional(),
2179
+ verbosity: z.enum(["verbose", "debug"]).optional()
2179
2180
  }).strict().optional();
2180
- async function tryXmlPath(inputPath, timeoutMs, signal) {
2181
+ var VERBOSITY_FLAGS = { verbose: "-v", debug: "-d" };
2182
+ function buildVerbosityArgs(verbosity) {
2183
+ if (verbosity !== void 0) {
2184
+ return [VERBOSITY_FLAGS[verbosity]];
2185
+ }
2186
+ return [];
2187
+ }
2188
+ async function tryXmlPath(inputPath, timeoutMs, signal, verbosity) {
2181
2189
  const xmlBinary = resolveBinary("dcm2xml");
2182
2190
  if (!xmlBinary.ok) {
2183
2191
  return err(xmlBinary.error);
2184
2192
  }
2185
- const xmlResult = await execCommand(xmlBinary.value, ["-nat", inputPath], { timeoutMs, signal });
2193
+ const xmlArgs = [...buildVerbosityArgs(verbosity), "-nat", inputPath];
2194
+ const xmlResult = await execCommand(xmlBinary.value, xmlArgs, { timeoutMs, signal });
2186
2195
  if (!xmlResult.ok) {
2187
2196
  return err(xmlResult.error);
2188
2197
  }
2189
2198
  if (xmlResult.value.exitCode !== 0) {
2190
- return err(createToolError("dcm2xml", ["-nat", inputPath], xmlResult.value.exitCode, xmlResult.value.stderr));
2199
+ return err(createToolError("dcm2xml", xmlArgs, xmlResult.value.exitCode, xmlResult.value.stderr));
2191
2200
  }
2192
2201
  const jsonResult = xmlToJson(xmlResult.value.stdout);
2193
2202
  if (!jsonResult.ok) {
@@ -2195,17 +2204,18 @@ async function tryXmlPath(inputPath, timeoutMs, signal) {
2195
2204
  }
2196
2205
  return ok({ data: jsonResult.value, source: "xml" });
2197
2206
  }
2198
- async function tryDirectPath(inputPath, timeoutMs, signal) {
2207
+ async function tryDirectPath(inputPath, timeoutMs, signal, verbosity) {
2199
2208
  const jsonBinary = resolveBinary("dcm2json");
2200
2209
  if (!jsonBinary.ok) {
2201
2210
  return err(jsonBinary.error);
2202
2211
  }
2203
- const result = await execCommand(jsonBinary.value, [inputPath], { timeoutMs, signal });
2212
+ const directArgs = [...buildVerbosityArgs(verbosity), inputPath];
2213
+ const result = await execCommand(jsonBinary.value, directArgs, { timeoutMs, signal });
2204
2214
  if (!result.ok) {
2205
2215
  return err(result.error);
2206
2216
  }
2207
2217
  if (result.value.exitCode !== 0) {
2208
- return err(createToolError("dcm2json", [inputPath], result.value.exitCode, result.value.stderr));
2218
+ return err(createToolError("dcm2json", directArgs, result.value.exitCode, result.value.stderr));
2209
2219
  }
2210
2220
  try {
2211
2221
  const repaired = repairJson(result.value.stdout);
@@ -2222,20 +2232,22 @@ async function dcm2json(inputPath, options) {
2222
2232
  }
2223
2233
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2224
2234
  const signal = options?.signal;
2235
+ const verbosity = options?.verbosity;
2225
2236
  if (options?.directOnly === true) {
2226
- return tryDirectPath(inputPath, timeoutMs, signal);
2237
+ return tryDirectPath(inputPath, timeoutMs, signal, verbosity);
2227
2238
  }
2228
- const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal);
2239
+ const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal, verbosity);
2229
2240
  if (xmlResult.ok) {
2230
2241
  return xmlResult;
2231
2242
  }
2232
- return tryDirectPath(inputPath, timeoutMs, signal);
2243
+ return tryDirectPath(inputPath, timeoutMs, signal, verbosity);
2233
2244
  }
2234
2245
  var TAG_OR_PATH_PATTERN = /^\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\](\.\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\])?)*)?$/;
2235
2246
  var TagModificationSchema = z.object({
2236
2247
  tag: z.string().regex(TAG_OR_PATH_PATTERN),
2237
2248
  value: z.string()
2238
2249
  });
2250
+ var VERBOSITY_FLAGS2 = { verbose: "-v", debug: "-d" };
2239
2251
  var DcmodifyOptionsSchema = z.object({
2240
2252
  timeoutMs: z.number().int().positive().optional(),
2241
2253
  signal: z.instanceof(AbortSignal).optional(),
@@ -2244,12 +2256,16 @@ var DcmodifyOptionsSchema = z.object({
2244
2256
  erasePrivateTags: z.boolean().optional(),
2245
2257
  noBackup: z.boolean().optional(),
2246
2258
  insertIfMissing: z.boolean().optional(),
2247
- ignoreMissingTags: z.boolean().optional()
2259
+ ignoreMissingTags: z.boolean().optional(),
2260
+ verbosity: z.enum(["verbose", "debug"]).optional()
2248
2261
  }).strict().refine((data) => data.modifications.length > 0 || data.erasures !== void 0 && data.erasures.length > 0 || data.erasePrivateTags === true, {
2249
2262
  message: "At least one of modifications, erasures, or erasePrivateTags is required"
2250
2263
  });
2251
2264
  function buildArgs3(inputPath, options) {
2252
2265
  const args = [];
2266
+ if (options.verbosity !== void 0) {
2267
+ args.push(VERBOSITY_FLAGS2[options.verbosity]);
2268
+ }
2253
2269
  if (options.noBackup !== false) {
2254
2270
  args.push("-nb");
2255
2271
  }
@@ -2659,8 +2675,9 @@ var DEFAULT_MAX_POOL_SIZE = 10;
2659
2675
  var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2660
2676
  var CONNECTION_RETRY_INTERVAL_MS = 500;
2661
2677
  var MAX_CONNECTION_RETRIES = 200;
2678
+ var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
2662
2679
  var DicomReceiverOptionsSchema = z.object({
2663
- port: z.number().int().min(1).max(65535),
2680
+ port: z.number().int().min(0).max(65535),
2664
2681
  storageDir: z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
2665
2682
  aeTitle: z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
2666
2683
  minPoolSize: z.number().int().min(1).max(100).optional(),
@@ -2668,6 +2685,9 @@ var DicomReceiverOptionsSchema = z.object({
2668
2685
  connectionTimeoutMs: z.number().int().positive().optional(),
2669
2686
  configFile: z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
2670
2687
  configProfile: z.string().min(1).optional(),
2688
+ acseTimeout: z.number().int().positive().optional(),
2689
+ dimseTimeout: z.number().int().positive().optional(),
2690
+ maxPdu: z.number().int().min(4096).max(131072).optional(),
2671
2691
  signal: z.instanceof(AbortSignal).optional()
2672
2692
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
2673
2693
  message: "minPoolSize must be <= maxPoolSize"
@@ -2796,6 +2816,57 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2796
2816
  onAssociationComplete(listener) {
2797
2817
  return this.on("ASSOCIATION_COMPLETE", listener);
2798
2818
  }
2819
+ /**
2820
+ * Registers a listener for new associations (bubbled from workers).
2821
+ *
2822
+ * @param listener - Callback receiving association received data
2823
+ * @returns this for chaining
2824
+ */
2825
+ onAssociationReceived(listener) {
2826
+ return this.on("ASSOCIATION_RECEIVED", listener);
2827
+ }
2828
+ /**
2829
+ * Registers a listener for C-STORE requests (bubbled from workers).
2830
+ *
2831
+ * @param listener - Callback receiving C-STORE request data
2832
+ * @returns this for chaining
2833
+ */
2834
+ onCStoreRequest(listener) {
2835
+ return this.on("C_STORE_REQUEST", listener);
2836
+ }
2837
+ /**
2838
+ * Registers a listener for C-ECHO requests (bubbled from workers).
2839
+ *
2840
+ * @param listener - Callback receiving echo request data
2841
+ * @returns this for chaining
2842
+ */
2843
+ onEchoRequest(listener) {
2844
+ return this.on("ECHO_REQUEST", listener);
2845
+ }
2846
+ /**
2847
+ * Registers a listener for refused associations (bubbled from workers).
2848
+ *
2849
+ * @param listener - Callback receiving refusing association data
2850
+ * @returns this for chaining
2851
+ */
2852
+ onRefusingAssociation(listener) {
2853
+ return this.on("REFUSING_ASSOCIATION", listener);
2854
+ }
2855
+ /**
2856
+ * Routes an external socket to an idle worker.
2857
+ *
2858
+ * The pool must be started via `start()` before calling this method.
2859
+ * Use this when managing your own TCP listener (e.g., protocol router).
2860
+ *
2861
+ * @param socket - An incoming net.Socket to route to a worker
2862
+ */
2863
+ handleSocket(socket) {
2864
+ if (!this.started) {
2865
+ socket.destroy(new Error("DicomReceiver: not started"));
2866
+ return;
2867
+ }
2868
+ void this.handleConnection(socket);
2869
+ }
2799
2870
  /** Current pool status. */
2800
2871
  get poolStatus() {
2801
2872
  let idle = 0;
@@ -2809,8 +2880,11 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2809
2880
  // -----------------------------------------------------------------------
2810
2881
  // TCP proxy
2811
2882
  // -----------------------------------------------------------------------
2812
- /** Starts the TCP proxy on the configured port. */
2883
+ /** Starts the TCP proxy on the configured port. Skips if port is 0. */
2813
2884
  startTcpProxy() {
2885
+ if (this.options.port === 0) {
2886
+ return Promise.resolve(ok(void 0));
2887
+ }
2814
2888
  return new Promise((resolve) => {
2815
2889
  this.tcpServer = net.createServer((socket) => {
2816
2890
  void this.handleConnection(socket);
@@ -2863,6 +2937,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2863
2937
  worker.associationDir = associationDir;
2864
2938
  worker.files = [];
2865
2939
  worker.fileSizes = [];
2940
+ worker.outputLines = [];
2866
2941
  worker.startAt = Date.now();
2867
2942
  this.pipeConnection(worker, remoteSocket);
2868
2943
  void this.replenishPool();
@@ -2921,6 +2996,19 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2921
2996
  }
2922
2997
  return ok(void 0);
2923
2998
  }
2999
+ /** Creates a Dcmrecv instance with the pool's shared options. */
3000
+ createDcmrecv(port, tempDir) {
3001
+ return Dcmrecv.create({
3002
+ port,
3003
+ aeTitle: this.options.aeTitle ?? "DCMRECV",
3004
+ outputDirectory: tempDir,
3005
+ configFile: this.options.configFile,
3006
+ configProfile: this.options.configProfile,
3007
+ acseTimeout: this.options.acseTimeout,
3008
+ dimseTimeout: this.options.dimseTimeout,
3009
+ maxPdu: this.options.maxPdu
3010
+ });
3011
+ }
2924
3012
  /** Spawns a single Dcmrecv worker with an ephemeral port. */
2925
3013
  async spawnWorker() {
2926
3014
  const portResult = await allocatePort();
@@ -2929,13 +3017,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2929
3017
  const tempDir = path.join(os.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
2930
3018
  const mkdirResult = await ensureDirectory(tempDir);
2931
3019
  if (!mkdirResult.ok) return mkdirResult;
2932
- const createResult = Dcmrecv.create({
2933
- port,
2934
- aeTitle: this.options.aeTitle ?? "DCMRECV",
2935
- outputDirectory: tempDir,
2936
- configFile: this.options.configFile,
2937
- configProfile: this.options.configProfile
2938
- });
3020
+ const createResult = this.createDcmrecv(port, tempDir);
2939
3021
  if (!createResult.ok) return createResult;
2940
3022
  const dcmrecv = createResult.value;
2941
3023
  const worker = {
@@ -2947,6 +3029,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2947
3029
  associationDir: void 0,
2948
3030
  files: [],
2949
3031
  fileSizes: [],
3032
+ outputLines: [],
2950
3033
  startAt: void 0,
2951
3034
  remoteSocket: void 0,
2952
3035
  workerSocket: void 0
@@ -3008,10 +3091,15 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3008
3091
  // -----------------------------------------------------------------------
3009
3092
  // Worker event wiring
3010
3093
  // -----------------------------------------------------------------------
3011
- /** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
3094
+ /** Wires all events on a worker: file handling, association lifecycle, output capture. */
3012
3095
  wireWorkerEvents(worker) {
3013
3096
  this.wireFileReceived(worker);
3014
3097
  this.wireAssociationComplete(worker);
3098
+ this.wireAssociationReceived(worker);
3099
+ this.wireCStoreRequest(worker);
3100
+ this.wireEchoRequest(worker);
3101
+ this.wireRefusingAssociation(worker);
3102
+ this.wireOutputCapture(worker);
3015
3103
  }
3016
3104
  /** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
3017
3105
  wireFileReceived(worker) {
@@ -3059,6 +3147,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3059
3147
  const assocId = worker.associationId ?? data.associationId;
3060
3148
  const assocDir = worker.associationDir ?? "";
3061
3149
  const files = [...worker.files];
3150
+ const output = [...worker.outputLines];
3062
3151
  const endAt = Date.now();
3063
3152
  const startAt = worker.startAt ?? endAt;
3064
3153
  const totalBytes = sumArray(worker.fileSizes);
@@ -3076,19 +3165,65 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3076
3165
  totalBytes,
3077
3166
  bytesPerSecond,
3078
3167
  startAt,
3079
- endAt
3168
+ endAt,
3169
+ output
3080
3170
  });
3081
3171
  worker.state = "idle";
3082
3172
  worker.associationId = void 0;
3083
3173
  worker.associationDir = void 0;
3084
3174
  worker.files = [];
3085
3175
  worker.fileSizes = [];
3176
+ worker.outputLines = [];
3086
3177
  worker.startAt = void 0;
3087
3178
  worker.remoteSocket = void 0;
3088
3179
  worker.workerSocket = void 0;
3089
3180
  void this.scaleDown();
3090
3181
  });
3091
3182
  }
3183
+ /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3184
+ wireAssociationReceived(worker) {
3185
+ worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3186
+ this.emit("ASSOCIATION_RECEIVED", {
3187
+ associationId: worker.associationId ?? "",
3188
+ callingAE: data.callingAE,
3189
+ calledAE: data.calledAE,
3190
+ source: data.source
3191
+ });
3192
+ });
3193
+ }
3194
+ /** Bubbles C_STORE_REQUEST from dcmrecv worker. */
3195
+ wireCStoreRequest(worker) {
3196
+ worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
3197
+ this.emit("C_STORE_REQUEST", {
3198
+ associationId: worker.associationId ?? "",
3199
+ raw: data.raw
3200
+ });
3201
+ });
3202
+ }
3203
+ /** Bubbles ECHO_REQUEST from dcmrecv worker. */
3204
+ wireEchoRequest(worker) {
3205
+ worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
3206
+ this.emit("ECHO_REQUEST", {
3207
+ associationId: worker.associationId ?? ""
3208
+ });
3209
+ });
3210
+ }
3211
+ /** Bubbles REFUSING_ASSOCIATION from dcmrecv worker. */
3212
+ wireRefusingAssociation(worker) {
3213
+ worker.dcmrecv.onEvent("REFUSING_ASSOCIATION", (data) => {
3214
+ this.emit("REFUSING_ASSOCIATION", {
3215
+ reason: data.reason
3216
+ });
3217
+ });
3218
+ }
3219
+ /** Captures worker output lines during busy associations. */
3220
+ wireOutputCapture(worker) {
3221
+ worker.dcmrecv.on("line", ({ text }) => {
3222
+ if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
3223
+ worker.outputLines.push(text);
3224
+ }
3225
+ });
3226
+ }
3092
3227
  // -----------------------------------------------------------------------
3093
3228
  // Abort signal
3094
3229
  // -----------------------------------------------------------------------