From 155a21432e8e5453a39e46a5177f0ddc7f1c09e0 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 9 Jul 2026 14:44:02 -0400 Subject: [PATCH 1/4] fix: deploydicomweb silent no-op and mkdicomweb require() failure under bun - Declare as a required positional on the studies command so commander reports a missing-argument error instead of exiting silently (previously the variadic -d option could swallow the study UID, leaving the command with no arguments and studiesMain returned without a message). - Import uids via its subpath in static-wado-creator index.mjs; mixing an ESM import of the static-wado-util package root with the CJS require() calls elsewhere in the package made bun treat the root as an async module and fail with "require() async module is unsupported". Co-Authored-By: Claude Fable 5 --- packages/static-wado-creator/lib/index.mjs | 5 ++++- packages/static-wado-deploy/lib/deployConfig.mjs | 5 ++--- packages/static-wado-deploy/lib/studiesMain.mjs | 5 ++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/static-wado-creator/lib/index.mjs b/packages/static-wado-creator/lib/index.mjs index db9dbe4a..b5b9fd21 100644 --- a/packages/static-wado-creator/lib/index.mjs +++ b/packages/static-wado-creator/lib/index.mjs @@ -4,7 +4,10 @@ import programIndex from './program/index.js'; import createMain from './createMain.js'; import deleteMain from './deleteMain.js'; import adaptProgramOpts from './util/adaptProgramOpts.js'; -import { uids } from '@radicalimaging/static-wado-util'; +// Import uids via its subpath rather than the package root: mixing an ESM +// import of the package root with the CJS require() calls in the lib/*.js +// files makes Bun treat the root as an async module and fail the require(). +import uids from '@radicalimaging/static-wado-util/uids.mjs'; const { configureProgram } = programIndex; diff --git a/packages/static-wado-deploy/lib/deployConfig.mjs b/packages/static-wado-deploy/lib/deployConfig.mjs index 5ee122e4..0604b0ea 100644 --- a/packages/static-wado-deploy/lib/deployConfig.mjs +++ b/packages/static-wado-deploy/lib/deployConfig.mjs @@ -91,9 +91,8 @@ const { deployConfig } = ConfigPoint.register({ programs: [ { - command: 'studies', - arguments: ['studies'], - helpShort: 'deploydicomweb studies studyUID', + command: 'studies ', + helpShort: 'deploydicomweb studies ', helpDescription: 'Deploy DICOMweb files to the cloud', options: [ { diff --git a/packages/static-wado-deploy/lib/studiesMain.mjs b/packages/static-wado-deploy/lib/studiesMain.mjs index 7ab58880..159fbd57 100644 --- a/packages/static-wado-deploy/lib/studiesMain.mjs +++ b/packages/static-wado-deploy/lib/studiesMain.mjs @@ -3,10 +3,9 @@ import uploadDeploy from './uploadDeploy.mjs'; import uploadIndex from './uploadIndex.mjs'; import retrieveDeploy from './retrieveDeploy.mjs'; -export default async function (options, program) { - const { args: studies } = program; +export default async function (studies, options) { if (!studies?.length) { - return; + throw new Error("No study UIDs specified - provide one or more study UIDs, or '*' for all studies."); } if (studies.length === 1 && studies[0] === '*') { From 7f2c01fe75b266440e532e84edcdc8ae7d65e9be Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 9 Jul 2026 14:59:18 -0400 Subject: [PATCH 2/4] fix: retrieve study-level index.json.gz to the correct local path On upload, fileToKey strips "/index.json.gz" so the study singleton index is stored at the S3 key "studies/". On retrieve, contentItemToFileName had no reverse mapping for that key shape and fell through to "studies/.gz", so the study index never landed at studies//index.json.gz and mkdicomweb index could not add the study to the overall studies index. dir() now detects directory-style index objects generically: any listed key that also has child keys maps back to /index.json.gz. This covers the study singleton, the overall studies index, and instance-level query indexes, without hardcoding path names. Co-Authored-By: Claude Fable 5 --- packages/s3-deploy/lib/S3Ops.mjs | 42 +++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/s3-deploy/lib/S3Ops.mjs b/packages/s3-deploy/lib/S3Ops.mjs index 9292bf9e..4444c40a 100644 --- a/packages/s3-deploy/lib/S3Ops.mjs +++ b/packages/s3-deploy/lib/S3Ops.mjs @@ -219,16 +219,47 @@ class S3Ops { return this.group.path ? contentItem.Key.substring(this.group.path.length) : contentItem.Key; } - contentItemToFileName(contentItem) { + contentItemToFileName(contentItem, isDirectory = false) { const s = this.getPath(contentItem); if (endsWith(s, 'thumbnail')) return s; if (endsWith(s, '/')) return `${s}index.json.gz`; - if (endsWith(s, '/series') || endsWith(s, '/studies') || endsWith(s, '/instances')) + if ( + isDirectory || + endsWith(s, '/series') || + endsWith(s, '/studies') || + endsWith(s, '/instances') + ) return `${s}/index.json.gz`; if (endsWith(s, '.gz') || endsWith(s, '.jls')) return s; return `${s}.gz`; } + /** + * Assigns the local fileName to each listed item. An object whose key also + * has children in the listing (e.g. "studies/" alongside + * "studies//series/...") is a directory index object created by + * fileToKey stripping "/index.json.gz" on upload, so it maps back to + * /index.json.gz rather than .gz. + */ + assignFileNames(results) { + const keys = results.map(it => it.Key).sort(); + const hasChildren = key => { + const childPrefix = `${key}/`; + let lo = 0; + let hi = keys.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (keys[mid] < childPrefix) lo = mid + 1; + else hi = mid; + } + return lo < keys.length && keys[lo].startsWith(childPrefix); + }; + for (const item of results) { + item.fileName = this.contentItemToFileName(item, hasChildren(item.Key)); + } + return results; + } + async dir(uri) { const remoteUri = this.remoteRelativeToUri(uri); if (!remoteUri) { @@ -256,11 +287,10 @@ class S3Ops { ...it, size: it.Size, relativeUri: this.getPath(it), - fileName: this.contentItemToFileName(it), }); }); if (!result.IsTruncated) { - return results; + return this.assignFileNames(results); } ContinuationToken = result.NextContinuationToken; if (!ContinuationToken) { @@ -268,10 +298,10 @@ class S3Ops { } } catch (e) { console.log('Error sending', Bucket, remoteUri, e); - return results; + return this.assignFileNames(results); } } - return results; + return this.assignFileNames(results); } async upload(dir, file, hash, excludeExisting = {}) { From 0e9edd11f3b9868f3a67458170814ee6766126c4 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 9 Jul 2026 15:16:08 -0400 Subject: [PATCH 3/4] fix: retrieve multipart frame/bulkdata files with their .mht names fileToKey strips the .mht extension on upload, so multipart objects live at extension-less keys (frames/1, bulkdata/xx/yy/zzzz). The directory listing can only guess ".gz" for those, which stored retrieved frames as 1.gz instead of 1.mht / 1.mht.gz. - S3Ops.retrieve now corrects the destination name from the object's ContentType/ContentEncoding: multipart/related objects are written as .mht, or .mht.gz when gzip encoded. - S3Ops.localCandidates lists the possible local names for a listed object so DeployGroup.retrieve can recognize already-retrieved .mht files and skip re-downloading them. - DeployGroup renames files stored under the generic ".gz" name by older retrieves to their correct multipart name, sniffing the leading bytes (multipart boundary, or gzip whose content starts with a boundary) so gzipped JSON like metadata.gz is left alone. Skipped on --dry-run. Co-Authored-By: Claude Fable 5 --- packages/s3-deploy/lib/S3Ops.mjs | 39 ++++++++++- .../static-wado-deploy/lib/DeployGroup.mjs | 66 ++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/s3-deploy/lib/S3Ops.mjs b/packages/s3-deploy/lib/S3Ops.mjs index 4444c40a..58ba671b 100644 --- a/packages/s3-deploy/lib/S3Ops.mjs +++ b/packages/s3-deploy/lib/S3Ops.mjs @@ -178,6 +178,42 @@ class S3Ops { } } + /** + * Restores the local file name for a multipart object whose ".mht" + * extension was stripped by fileToKey on upload. The directory listing + * can only guess ".gz" for extension-less keys; the object's + * content type and encoding (known once it is fetched) determine the + * real name: .mht for plain multipart, .mht.gz when the + * object is gzip encoded. + */ + retrieveFileName(destFile, contentType, contentEncoding) { + if (!contentType || !contentType.startsWith(multipartRelated)) return destFile; + if (!endsWith(destFile, '.gz')) return destFile; + const base = destFile.substring(0, destFile.length - 3); + const lastSegment = base.substring( + Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\')) + 1 + ); + if (lastSegment.indexOf('.') !== -1) return destFile; + return contentEncoding === 'gzip' ? `${base}.mht.gz` : `${base}.mht`; + } + + /** + * The local file names a listed object may be stored under. An + * extension-less key guessed as ".gz" may really be a multipart + * file stored locally as .mht or .mht.gz. + */ + localCandidates(fileName) { + const candidates = [fileName]; + if (endsWith(fileName, '.gz')) { + const base = fileName.substring(0, fileName.length - 3); + const lastSegment = base.substring(base.lastIndexOf('/') + 1); + if (lastSegment.indexOf('.') === -1) { + candidates.push(`${base}.mht`, `${base}.mht.gz`); + } + } + return candidates; + } + /** Retrieves the given s3 URI to the specified destination path */ async retrieve(uri, destFile, options = { force: false }) { const remoteUri = this.remoteRelativeToUri(uri); @@ -204,7 +240,8 @@ class S3Ops { try { const result = await this.client.send(command); - const { Body } = result; + const { Body, ContentType, ContentEncoding } = result; + destFile = this.retrieveFileName(destFile, ContentType, ContentEncoding); await copyTo(Body, destFile); console.info('Retrieved', Key); } catch (e) { diff --git a/packages/static-wado-deploy/lib/DeployGroup.mjs b/packages/static-wado-deploy/lib/DeployGroup.mjs index f4ae67a4..90c82834 100644 --- a/packages/static-wado-deploy/lib/DeployGroup.mjs +++ b/packages/static-wado-deploy/lib/DeployGroup.mjs @@ -1,4 +1,5 @@ import fs from 'fs'; +import zlib from 'zlib'; import { configGroup, handleHomeRelative } from '@radicalimaging/static-wado-util'; import path from 'path'; import { plugins } from '@radicalimaging/static-wado-plugins'; @@ -333,6 +334,64 @@ class DeployGroup { } } + /** + * Finds the local file a listed object is already stored under, checking + * the alternate .mht names a multipart object may have locally. A file + * left under the generic ".gz" name by an older retrieve is renamed to + * its correct multipart name, determined from its leading bytes. + * @returns {string|undefined} Full path of the existing local file + */ + findExistingLocal(fileName) { + const candidates = this.ops.localCandidates ? this.ops.localCandidates(fileName) : [fileName]; + const existing = candidates.find(name => fs.existsSync(path.join(this.baseDir, name))); + if (existing === undefined) return undefined; + const existingPath = path.join(this.baseDir, existing); + if (existing === fileName && candidates.length > 1 && !this.options.dryRun) { + return this.renameLegacyMultipart(existingPath) || existingPath; + } + return existingPath; + } + + /** + * Renames a multipart file stored under a plain ".gz" name to + * .mht or .mht.gz according to its content. Files that are + * not multipart (e.g. gzipped JSON such as metadata.gz) are left alone. + * @returns {string|undefined} The corrected path, if renamed + */ + renameLegacyMultipart(existingPath) { + const fd = fs.openSync(existingPath, 'r'); + const header = Buffer.alloc(1024); + let bytesRead; + try { + bytesRead = fs.readSync(fd, header, 0, header.length, 0); + } finally { + fs.closeSync(fd); + } + if (bytesRead < 2) return undefined; + const base = existingPath.substring(0, existingPath.length - 3); + let corrected; + if (header[0] === 0x2d && header[1] === 0x2d) { + // Multipart boundary "--" - an uncompressed .mht stored as .gz + corrected = `${base}.mht`; + } else if (header[0] === 0x1f && header[1] === 0x8b) { + // Gzip data - only rename when the compressed content is multipart + try { + const inflated = zlib.gunzipSync(header.subarray(0, bytesRead), { + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }); + if (inflated[0] === 0x2d && inflated[1] === 0x2d) { + corrected = `${base}.mht.gz`; + } + } catch (e) { + console.verbose('Unable to inspect gzip content of', existingPath, e.message); + } + } + if (!corrected || fs.existsSync(corrected)) return undefined; + fs.renameSync(existingPath, corrected); + console.noQuiet('Renamed', existingPath, 'to', corrected); + return corrected; + } + async dir(uri) { const list = await this.ops.dir(uri); return list.reduce((acc, value) => { @@ -388,9 +447,10 @@ class DeployGroup { if (isExcluded) { continue; } - if (fs.existsSync(destName) && !force) { - if (this.ops.shouldSkip(item, destName)) { - console.verbose('Skipping', destName); + if (!force) { + const existingPath = this.findExistingLocal(item.fileName); + if (existingPath && this.ops.shouldSkip(item, existingPath)) { + console.verbose('Skipping', existingPath); skippedItems += 1; continue; } From b80d6c522ef6b864e7cfb37ef5ec56a9a2b047b1 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 9 Jul 2026 16:52:55 -0400 Subject: [PATCH 4/4] Fix download/upload issues to and from s3 --- .../lib/instance/instanceFromStream.mjs | 69 ++++++++++++++ packages/s3-deploy/lib/S3Ops.mjs | 16 +++- .../static-wado-deploy/lib/DeployGroup.mjs | 7 +- .../controllers/server/part10Controller.mjs | 93 ++++++++++++++++++- 4 files changed, 181 insertions(+), 4 deletions(-) diff --git a/packages/create-dicomweb/lib/instance/instanceFromStream.mjs b/packages/create-dicomweb/lib/instance/instanceFromStream.mjs index f18a0c51..6b7f2610 100644 --- a/packages/create-dicomweb/lib/instance/instanceFromStream.mjs +++ b/packages/create-dicomweb/lib/instance/instanceFromStream.mjs @@ -1,4 +1,5 @@ import { async, utilities, data } from 'dcmjs'; +import { v4 as uuid } from 'uuid'; import { Tags, StatusMonitor, createPromiseTracker } from '@radicalimaging/static-wado-util'; import { writeMultipartFramesFilter } from './writeMultipartFramesFilter.mjs'; import { writeBulkdataFilter } from './writeBulkdataFilter.mjs'; @@ -28,6 +29,60 @@ function isReadBufferStreamLike(stream) { const PARSE_JOB_TYPE = 'stowInstanceParse'; +/** Modalities whose original Part 10 binary is stored as instances//index.mht.gz */ +const RAW_PART10_MODALITIES = new Set(['SEG', 'SR']); +/** SOP Classes whose original Part 10 binary is stored (Basic Structured Display) */ +const RAW_PART10_SOP_CLASSES = new Set(['1.2.840.10008.5.1.4.1.1.30']); + +/** + * Returns true when the original Part 10 binary should be stored for this + * instance (segmentations and structured reports), mirroring the + * static-wado-creator RawDicomWriter selector. + * @param {Object} dict - Parsed instance dataset (DICOM JSON model) + * @returns {boolean} + */ +function shouldStoreRawPart10(dict) { + const modality = dict?.[Tags.Modality]?.Value?.[0]; + const sopClass = dict?.[Tags.SOPClassUID]?.Value?.[0]; + return RAW_PART10_MODALITIES.has(modality) || RAW_PART10_SOP_CLASSES.has(sopClass); +} + +/** + * Writes the original Part 10 bytes (as received) to the instance-level + * rendition instances//index.mht.gz - a gzipped multipart/related wrapper + * with Content-Type: application/dicom, the same format RawDicomWriter used + * and the format dicomwebserver serves for instance retrieval. + * The source bytes are re-read from the parse stream, which retains its + * buffers on the STOW path (clearBuffers is false there). + * @param {Object} writer - DicomWebWriter used for this instance + * @param {Object} stream - The stream the instance was parsed from + * @param {string} sopInstanceUID - For logging + * @returns {Promise} + */ +async function writeRawPart10(writer, stream, sopInstanceUID) { + const rawSize = stream?.size; + const canReRead = + typeof stream?.getBuffer === 'function' && + rawSize > 0 && + (typeof stream.hasData !== 'function' || stream.hasData(0, rawSize)); + if (!canReRead) { + console.verbose('Raw Part 10 not stored for', sopInstanceUID, '- source bytes unavailable'); + return; + } + const raw = stream.getBuffer(0, rawSize); + const rawBuffer = ArrayBuffer.isView(raw) + ? Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength) + : Buffer.from(raw); + const rawStream = await writer.openInstanceStream('index.mht', { + gzip: true, + multipart: true, + contentType: 'application/dicom', + boundary: `BOUNDARY_${uuid()}`, + }); + rawStream.stream.write(rawBuffer); + await writer.closeStream(rawStream.streamKey); +} + /** * Creates a filter that counts addTag/value calls and reports progress to StatusMonitor. * Uses a per-instance parse job so counts are not overwritten when multiple instances parse concurrently. @@ -325,6 +380,20 @@ export async function instanceFromStream(stream, options = {}) { await writer.closeStream(metadataStream.streamKey); } + // Store the original Part 10 binary for segmentations/structured reports so + // instance retrieval can serve it directly instead of re-creating it. + if (writer && options.writeRawPart10 !== false && shouldStoreRawPart10(dict)) { + try { + await writeRawPart10(writer, reader.stream, information.sopInstanceUid); + } catch (err) { + console.warn( + 'Unable to store raw Part 10 for', + information.sopInstanceUid, + err?.message ?? err + ); + } + } + // Wait for all frame writes to complete before returning await writer?.awaitAllStreams(); console.verbose('Finished writing metadata to file', information.sopInstanceUid); diff --git a/packages/s3-deploy/lib/S3Ops.mjs b/packages/s3-deploy/lib/S3Ops.mjs index 58ba671b..ba77b6aa 100644 --- a/packages/s3-deploy/lib/S3Ops.mjs +++ b/packages/s3-deploy/lib/S3Ops.mjs @@ -189,11 +189,18 @@ class S3Ops { retrieveFileName(destFile, contentType, contentEncoding) { if (!contentType || !contentType.startsWith(multipartRelated)) return destFile; if (!endsWith(destFile, '.gz')) return destFile; - const base = destFile.substring(0, destFile.length - 3); + let base = destFile.substring(0, destFile.length - 3); const lastSegment = base.substring( Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\')) + 1 ); - if (lastSegment.indexOf('.') !== -1) return destFile; + if (lastSegment === 'index.json') { + // Directory-index guess for a key with children (e.g. the instance-level + // Part 10 rendition at .../instances/): multipart content really + // lives at /index.mht rather than /index.json. + base = base.substring(0, base.length - '.json'.length); + } else if (lastSegment.indexOf('.') !== -1) { + return destFile; + } return contentEncoding === 'gzip' ? `${base}.mht.gz` : `${base}.mht`; } @@ -209,6 +216,11 @@ class S3Ops { const lastSegment = base.substring(base.lastIndexOf('/') + 1); if (lastSegment.indexOf('.') === -1) { candidates.push(`${base}.mht`, `${base}.mht.gz`); + } else if (lastSegment === 'index.json') { + // Directory-index guess may really be a multipart rendition at + // /index.mht(.gz) (e.g. instance-level Part 10 files). + const indexBase = base.substring(0, base.length - '.json'.length); + candidates.push(`${indexBase}.mht`, `${indexBase}.mht.gz`); } } return candidates; diff --git a/packages/static-wado-deploy/lib/DeployGroup.mjs b/packages/static-wado-deploy/lib/DeployGroup.mjs index 90c82834..547e8a5f 100644 --- a/packages/static-wado-deploy/lib/DeployGroup.mjs +++ b/packages/static-wado-deploy/lib/DeployGroup.mjs @@ -368,7 +368,12 @@ class DeployGroup { fs.closeSync(fd); } if (bytesRead < 2) return undefined; - const base = existingPath.substring(0, existingPath.length - 3); + let base = existingPath.substring(0, existingPath.length - 3); + if (base.endsWith('.json')) { + // A directory-index guess (index.json.gz) holding multipart content + // belongs at index.mht(.gz) + base = base.substring(0, base.length - '.json'.length); + } let corrected; if (header[0] === 0x2d && header[1] === 0x2d) { // Multipart boundary "--" - an uncompressed .mht stored as .gz diff --git a/packages/static-wado-webserver/lib/controllers/server/part10Controller.mjs b/packages/static-wado-webserver/lib/controllers/server/part10Controller.mjs index dd7221a6..b7ee75fd 100644 --- a/packages/static-wado-webserver/lib/controllers/server/part10Controller.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/part10Controller.mjs @@ -1,7 +1,53 @@ +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; import { handleHomeRelative } from '@radicalimaging/static-wado-util'; const MULTIPART_BOUNDARY = '----DICOMwebBoundary'; +/** + * Locates the already-stored Part 10 rendition for an instance: + * instances//index.mht.gz (or index.mht), as written by STOW / + * mkdicomweb or retrieved by deploydicomweb. + * @param {string} baseDir - DICOMweb root directory + * @param {string} staticWadoPath - Request path (hash-mapped when applicable) + * @returns {string|null} - Full path of the stored file, or null + */ +function findStoredRendition(baseDir, staticWadoPath) { + if (!staticWadoPath) return null; + const instanceDir = path.join(baseDir, ...staticWadoPath.split('/').filter(Boolean)); + for (const name of ['index.mht.gz', 'index.mht']) { + const filePath = path.join(instanceDir, name); + if (fs.existsSync(filePath)) return filePath; + } + return null; +} + +/** + * Reads a stored index.mht/index.mht.gz file and extracts the raw Part 10 + * payload from its multipart/related wrapper. + * @param {string} filePath - Path to the stored file + * @returns {Buffer|null} - Raw Part 10 bytes, or null if not multipart + */ +function readStoredPart10(filePath) { + let data = fs.readFileSync(filePath); + if (filePath.endsWith('.gz')) { + data = zlib.gunzipSync(data); + } + // Expect a multipart wrapper: --\r\n\r\n\r\n\r\n---- + if (data.length < 4 || data[0] !== 0x2d || data[1] !== 0x2d) return null; + const firstLineEnd = data.indexOf('\r\n'); + if (firstLineEnd === -1) return null; + const boundary = data.subarray(2, firstLineEnd).toString('latin1').trim(); + const headerEnd = data.indexOf('\r\n\r\n', firstLineEnd); + if (headerEnd === -1) return null; + const payloadStart = headerEnd + 4; + const trailer = Buffer.from(`\r\n--${boundary}--`); + let payloadEnd = data.lastIndexOf(trailer); + if (payloadEnd < payloadStart) payloadEnd = data.length; + return data.subarray(payloadStart, payloadEnd); +} + /** * Checks whether a value explicitly requests Part 10 multipart/related * (i.e. type="application/dicom"), as opposed to standard WADO-RS @@ -59,6 +105,12 @@ function getAcceptType(req) { * - application/zip: zip archive containing Part 10 files * - multipart/related: multipart/related wrapping Part 10 binary (default for Part 10 requests) * + * Serves the already-stored rendition (instances//index.mht.gz) when it + * exists - for multipart/related by falling through to dicomMap/static serving, + * otherwise by unwrapping the stored multipart payload. Only when no stored file + * exists (or it is unreadable) is the Part 10 data re-created from metadata and + * bulkdata as a backup. + * * If no Part 10-specific accept type is detected, calls next() to fall through * to the existing dicomMap handler. * @@ -66,7 +118,7 @@ function getAcceptType(req) { * @returns {Function} Express middleware */ export default function part10Controller(options) { - const baseDir = handleHomeRelative(options.dir); + const baseDir = handleHomeRelative(options.dir ?? options.rootDir); return async (req, res, next) => { const acceptType = getAcceptType(req); @@ -78,6 +130,45 @@ export default function part10Controller(options) { const { studyUID, seriesUID, instanceUID } = req.params; + // Prefer the already-stored (compressed) rendition; only re-create as a backup. + const storedPath = findStoredRendition(baseDir, req.staticWadoPath ?? req.path); + if (storedPath) { + if (acceptType === 'multipart/related') { + if (storedPath.endsWith('.gz')) { + // Fall through to dicomMap + the static controllers, which serve the + // stored index.mht.gz directly (gzip-encoded multipart/related). + return next(); + } + // Plain index.mht - dicomMap only maps to index.mht.gz, so send directly + res.setHeader('Content-Type', 'multipart/related; type="application/dicom"'); + res.sendFile(path.resolve(storedPath)); + return; + } + try { + const dcmBuffer = readStoredPart10(storedPath); + if (dcmBuffer) { + if (acceptType === 'application/dicom') { + res.setHeader('Content-Type', 'application/dicom'); + res.setHeader('Content-Disposition', `attachment; filename="${instanceUID}.dcm"`); + res.send(dcmBuffer); + return; + } + if (acceptType === 'application/zip') { + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(); + zip.addFile(`${instanceUID}.dcm`, dcmBuffer); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${studyUID}.zip"`); + res.send(zip.toBuffer()); + return; + } + } + } catch (error) { + console.warn(`Unable to serve stored Part 10 file ${storedPath}: ${error.message}`); + } + // Stored file unusable for this accept type - fall through to generation + } + try { // Dynamic import to avoid circular dependency at load time const { generatePart10ForStudy } = await import('@radicalimaging/create-dicomweb');