@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.cjs CHANGED
@@ -2201,19 +2201,28 @@ function repairJson(raw) {
2201
2201
  var Dcm2jsonOptionsSchema = zod.z.object({
2202
2202
  timeoutMs: zod.z.number().int().positive().optional(),
2203
2203
  signal: zod.z.instanceof(AbortSignal).optional(),
2204
- directOnly: zod.z.boolean().optional()
2204
+ directOnly: zod.z.boolean().optional(),
2205
+ verbosity: zod.z.enum(["verbose", "debug"]).optional()
2205
2206
  }).strict().optional();
2206
- async function tryXmlPath(inputPath, timeoutMs, signal) {
2207
+ var VERBOSITY_FLAGS = { verbose: "-v", debug: "-d" };
2208
+ function buildVerbosityArgs(verbosity) {
2209
+ if (verbosity !== void 0) {
2210
+ return [VERBOSITY_FLAGS[verbosity]];
2211
+ }
2212
+ return [];
2213
+ }
2214
+ async function tryXmlPath(inputPath, timeoutMs, signal, verbosity) {
2207
2215
  const xmlBinary = resolveBinary("dcm2xml");
2208
2216
  if (!xmlBinary.ok) {
2209
2217
  return err(xmlBinary.error);
2210
2218
  }
2211
- const xmlResult = await execCommand(xmlBinary.value, ["-nat", inputPath], { timeoutMs, signal });
2219
+ const xmlArgs = [...buildVerbosityArgs(verbosity), "-nat", inputPath];
2220
+ const xmlResult = await execCommand(xmlBinary.value, xmlArgs, { timeoutMs, signal });
2212
2221
  if (!xmlResult.ok) {
2213
2222
  return err(xmlResult.error);
2214
2223
  }
2215
2224
  if (xmlResult.value.exitCode !== 0) {
2216
- return err(createToolError("dcm2xml", ["-nat", inputPath], xmlResult.value.exitCode, xmlResult.value.stderr));
2225
+ return err(createToolError("dcm2xml", xmlArgs, xmlResult.value.exitCode, xmlResult.value.stderr));
2217
2226
  }
2218
2227
  const jsonResult = xmlToJson(xmlResult.value.stdout);
2219
2228
  if (!jsonResult.ok) {
@@ -2221,17 +2230,18 @@ async function tryXmlPath(inputPath, timeoutMs, signal) {
2221
2230
  }
2222
2231
  return ok({ data: jsonResult.value, source: "xml" });
2223
2232
  }
2224
- async function tryDirectPath(inputPath, timeoutMs, signal) {
2233
+ async function tryDirectPath(inputPath, timeoutMs, signal, verbosity) {
2225
2234
  const jsonBinary = resolveBinary("dcm2json");
2226
2235
  if (!jsonBinary.ok) {
2227
2236
  return err(jsonBinary.error);
2228
2237
  }
2229
- const result = await execCommand(jsonBinary.value, [inputPath], { timeoutMs, signal });
2238
+ const directArgs = [...buildVerbosityArgs(verbosity), inputPath];
2239
+ const result = await execCommand(jsonBinary.value, directArgs, { timeoutMs, signal });
2230
2240
  if (!result.ok) {
2231
2241
  return err(result.error);
2232
2242
  }
2233
2243
  if (result.value.exitCode !== 0) {
2234
- return err(createToolError("dcm2json", [inputPath], result.value.exitCode, result.value.stderr));
2244
+ return err(createToolError("dcm2json", directArgs, result.value.exitCode, result.value.stderr));
2235
2245
  }
2236
2246
  try {
2237
2247
  const repaired = repairJson(result.value.stdout);
@@ -2248,20 +2258,22 @@ async function dcm2json(inputPath, options) {
2248
2258
  }
2249
2259
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2250
2260
  const signal = options?.signal;
2261
+ const verbosity = options?.verbosity;
2251
2262
  if (options?.directOnly === true) {
2252
- return tryDirectPath(inputPath, timeoutMs, signal);
2263
+ return tryDirectPath(inputPath, timeoutMs, signal, verbosity);
2253
2264
  }
2254
- const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal);
2265
+ const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal, verbosity);
2255
2266
  if (xmlResult.ok) {
2256
2267
  return xmlResult;
2257
2268
  }
2258
- return tryDirectPath(inputPath, timeoutMs, signal);
2269
+ return tryDirectPath(inputPath, timeoutMs, signal, verbosity);
2259
2270
  }
2260
2271
  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+\])?)*)?$/;
2261
2272
  var TagModificationSchema = zod.z.object({
2262
2273
  tag: zod.z.string().regex(TAG_OR_PATH_PATTERN),
2263
2274
  value: zod.z.string()
2264
2275
  });
2276
+ var VERBOSITY_FLAGS2 = { verbose: "-v", debug: "-d" };
2265
2277
  var DcmodifyOptionsSchema = zod.z.object({
2266
2278
  timeoutMs: zod.z.number().int().positive().optional(),
2267
2279
  signal: zod.z.instanceof(AbortSignal).optional(),
@@ -2270,12 +2282,16 @@ var DcmodifyOptionsSchema = zod.z.object({
2270
2282
  erasePrivateTags: zod.z.boolean().optional(),
2271
2283
  noBackup: zod.z.boolean().optional(),
2272
2284
  insertIfMissing: zod.z.boolean().optional(),
2273
- ignoreMissingTags: zod.z.boolean().optional()
2285
+ ignoreMissingTags: zod.z.boolean().optional(),
2286
+ verbosity: zod.z.enum(["verbose", "debug"]).optional()
2274
2287
  }).strict().refine((data) => data.modifications.length > 0 || data.erasures !== void 0 && data.erasures.length > 0 || data.erasePrivateTags === true, {
2275
2288
  message: "At least one of modifications, erasures, or erasePrivateTags is required"
2276
2289
  });
2277
2290
  function buildArgs3(inputPath, options) {
2278
2291
  const args = [];
2292
+ if (options.verbosity !== void 0) {
2293
+ args.push(VERBOSITY_FLAGS2[options.verbosity]);
2294
+ }
2279
2295
  if (options.noBackup !== false) {
2280
2296
  args.push("-nb");
2281
2297
  }
@@ -2685,8 +2701,9 @@ var DEFAULT_MAX_POOL_SIZE = 10;
2685
2701
  var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2686
2702
  var CONNECTION_RETRY_INTERVAL_MS = 500;
2687
2703
  var MAX_CONNECTION_RETRIES = 200;
2704
+ var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
2688
2705
  var DicomReceiverOptionsSchema = zod.z.object({
2689
- port: zod.z.number().int().min(1).max(65535),
2706
+ port: zod.z.number().int().min(0).max(65535),
2690
2707
  storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
2691
2708
  aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
2692
2709
  minPoolSize: zod.z.number().int().min(1).max(100).optional(),
@@ -2694,6 +2711,9 @@ var DicomReceiverOptionsSchema = zod.z.object({
2694
2711
  connectionTimeoutMs: zod.z.number().int().positive().optional(),
2695
2712
  configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
2696
2713
  configProfile: zod.z.string().min(1).optional(),
2714
+ acseTimeout: zod.z.number().int().positive().optional(),
2715
+ dimseTimeout: zod.z.number().int().positive().optional(),
2716
+ maxPdu: zod.z.number().int().min(4096).max(131072).optional(),
2697
2717
  signal: zod.z.instanceof(AbortSignal).optional()
2698
2718
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
2699
2719
  message: "minPoolSize must be <= maxPoolSize"
@@ -2822,6 +2842,57 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2822
2842
  onAssociationComplete(listener) {
2823
2843
  return this.on("ASSOCIATION_COMPLETE", listener);
2824
2844
  }
2845
+ /**
2846
+ * Registers a listener for new associations (bubbled from workers).
2847
+ *
2848
+ * @param listener - Callback receiving association received data
2849
+ * @returns this for chaining
2850
+ */
2851
+ onAssociationReceived(listener) {
2852
+ return this.on("ASSOCIATION_RECEIVED", listener);
2853
+ }
2854
+ /**
2855
+ * Registers a listener for C-STORE requests (bubbled from workers).
2856
+ *
2857
+ * @param listener - Callback receiving C-STORE request data
2858
+ * @returns this for chaining
2859
+ */
2860
+ onCStoreRequest(listener) {
2861
+ return this.on("C_STORE_REQUEST", listener);
2862
+ }
2863
+ /**
2864
+ * Registers a listener for C-ECHO requests (bubbled from workers).
2865
+ *
2866
+ * @param listener - Callback receiving echo request data
2867
+ * @returns this for chaining
2868
+ */
2869
+ onEchoRequest(listener) {
2870
+ return this.on("ECHO_REQUEST", listener);
2871
+ }
2872
+ /**
2873
+ * Registers a listener for refused associations (bubbled from workers).
2874
+ *
2875
+ * @param listener - Callback receiving refusing association data
2876
+ * @returns this for chaining
2877
+ */
2878
+ onRefusingAssociation(listener) {
2879
+ return this.on("REFUSING_ASSOCIATION", listener);
2880
+ }
2881
+ /**
2882
+ * Routes an external socket to an idle worker.
2883
+ *
2884
+ * The pool must be started via `start()` before calling this method.
2885
+ * Use this when managing your own TCP listener (e.g., protocol router).
2886
+ *
2887
+ * @param socket - An incoming net.Socket to route to a worker
2888
+ */
2889
+ handleSocket(socket) {
2890
+ if (!this.started) {
2891
+ socket.destroy(new Error("DicomReceiver: not started"));
2892
+ return;
2893
+ }
2894
+ void this.handleConnection(socket);
2895
+ }
2825
2896
  /** Current pool status. */
2826
2897
  get poolStatus() {
2827
2898
  let idle = 0;
@@ -2835,8 +2906,11 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2835
2906
  // -----------------------------------------------------------------------
2836
2907
  // TCP proxy
2837
2908
  // -----------------------------------------------------------------------
2838
- /** Starts the TCP proxy on the configured port. */
2909
+ /** Starts the TCP proxy on the configured port. Skips if port is 0. */
2839
2910
  startTcpProxy() {
2911
+ if (this.options.port === 0) {
2912
+ return Promise.resolve(ok(void 0));
2913
+ }
2840
2914
  return new Promise((resolve) => {
2841
2915
  this.tcpServer = net__namespace.createServer((socket) => {
2842
2916
  void this.handleConnection(socket);
@@ -2889,6 +2963,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2889
2963
  worker.associationDir = associationDir;
2890
2964
  worker.files = [];
2891
2965
  worker.fileSizes = [];
2966
+ worker.outputLines = [];
2892
2967
  worker.startAt = Date.now();
2893
2968
  this.pipeConnection(worker, remoteSocket);
2894
2969
  void this.replenishPool();
@@ -2947,6 +3022,19 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2947
3022
  }
2948
3023
  return ok(void 0);
2949
3024
  }
3025
+ /** Creates a Dcmrecv instance with the pool's shared options. */
3026
+ createDcmrecv(port, tempDir) {
3027
+ return Dcmrecv.create({
3028
+ port,
3029
+ aeTitle: this.options.aeTitle ?? "DCMRECV",
3030
+ outputDirectory: tempDir,
3031
+ configFile: this.options.configFile,
3032
+ configProfile: this.options.configProfile,
3033
+ acseTimeout: this.options.acseTimeout,
3034
+ dimseTimeout: this.options.dimseTimeout,
3035
+ maxPdu: this.options.maxPdu
3036
+ });
3037
+ }
2950
3038
  /** Spawns a single Dcmrecv worker with an ephemeral port. */
2951
3039
  async spawnWorker() {
2952
3040
  const portResult = await allocatePort();
@@ -2955,13 +3043,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2955
3043
  const tempDir = path__namespace.join(os__namespace.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
2956
3044
  const mkdirResult = await ensureDirectory(tempDir);
2957
3045
  if (!mkdirResult.ok) return mkdirResult;
2958
- const createResult = Dcmrecv.create({
2959
- port,
2960
- aeTitle: this.options.aeTitle ?? "DCMRECV",
2961
- outputDirectory: tempDir,
2962
- configFile: this.options.configFile,
2963
- configProfile: this.options.configProfile
2964
- });
3046
+ const createResult = this.createDcmrecv(port, tempDir);
2965
3047
  if (!createResult.ok) return createResult;
2966
3048
  const dcmrecv = createResult.value;
2967
3049
  const worker = {
@@ -2973,6 +3055,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
2973
3055
  associationDir: void 0,
2974
3056
  files: [],
2975
3057
  fileSizes: [],
3058
+ outputLines: [],
2976
3059
  startAt: void 0,
2977
3060
  remoteSocket: void 0,
2978
3061
  workerSocket: void 0
@@ -3034,10 +3117,15 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3034
3117
  // -----------------------------------------------------------------------
3035
3118
  // Worker event wiring
3036
3119
  // -----------------------------------------------------------------------
3037
- /** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
3120
+ /** Wires all events on a worker: file handling, association lifecycle, output capture. */
3038
3121
  wireWorkerEvents(worker) {
3039
3122
  this.wireFileReceived(worker);
3040
3123
  this.wireAssociationComplete(worker);
3124
+ this.wireAssociationReceived(worker);
3125
+ this.wireCStoreRequest(worker);
3126
+ this.wireEchoRequest(worker);
3127
+ this.wireRefusingAssociation(worker);
3128
+ this.wireOutputCapture(worker);
3041
3129
  }
3042
3130
  /** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
3043
3131
  wireFileReceived(worker) {
@@ -3085,6 +3173,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3085
3173
  const assocId = worker.associationId ?? data.associationId;
3086
3174
  const assocDir = worker.associationDir ?? "";
3087
3175
  const files = [...worker.files];
3176
+ const output = [...worker.outputLines];
3088
3177
  const endAt = Date.now();
3089
3178
  const startAt = worker.startAt ?? endAt;
3090
3179
  const totalBytes = sumArray(worker.fileSizes);
@@ -3102,19 +3191,65 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3102
3191
  totalBytes,
3103
3192
  bytesPerSecond,
3104
3193
  startAt,
3105
- endAt
3194
+ endAt,
3195
+ output
3106
3196
  });
3107
3197
  worker.state = "idle";
3108
3198
  worker.associationId = void 0;
3109
3199
  worker.associationDir = void 0;
3110
3200
  worker.files = [];
3111
3201
  worker.fileSizes = [];
3202
+ worker.outputLines = [];
3112
3203
  worker.startAt = void 0;
3113
3204
  worker.remoteSocket = void 0;
3114
3205
  worker.workerSocket = void 0;
3115
3206
  void this.scaleDown();
3116
3207
  });
3117
3208
  }
3209
+ /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3210
+ wireAssociationReceived(worker) {
3211
+ worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3212
+ this.emit("ASSOCIATION_RECEIVED", {
3213
+ associationId: worker.associationId ?? "",
3214
+ callingAE: data.callingAE,
3215
+ calledAE: data.calledAE,
3216
+ source: data.source
3217
+ });
3218
+ });
3219
+ }
3220
+ /** Bubbles C_STORE_REQUEST from dcmrecv worker. */
3221
+ wireCStoreRequest(worker) {
3222
+ worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
3223
+ this.emit("C_STORE_REQUEST", {
3224
+ associationId: worker.associationId ?? "",
3225
+ raw: data.raw
3226
+ });
3227
+ });
3228
+ }
3229
+ /** Bubbles ECHO_REQUEST from dcmrecv worker. */
3230
+ wireEchoRequest(worker) {
3231
+ worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
3232
+ this.emit("ECHO_REQUEST", {
3233
+ associationId: worker.associationId ?? ""
3234
+ });
3235
+ });
3236
+ }
3237
+ /** Bubbles REFUSING_ASSOCIATION from dcmrecv worker. */
3238
+ wireRefusingAssociation(worker) {
3239
+ worker.dcmrecv.onEvent("REFUSING_ASSOCIATION", (data) => {
3240
+ this.emit("REFUSING_ASSOCIATION", {
3241
+ reason: data.reason
3242
+ });
3243
+ });
3244
+ }
3245
+ /** Captures worker output lines during busy associations. */
3246
+ wireOutputCapture(worker) {
3247
+ worker.dcmrecv.on("line", ({ text }) => {
3248
+ if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
3249
+ worker.outputLines.push(text);
3250
+ }
3251
+ });
3252
+ }
3118
3253
  // -----------------------------------------------------------------------
3119
3254
  // Abort signal
3120
3255
  // -----------------------------------------------------------------------