cod-dicomweb-server
Version:
A wadors server proxy that get data from a Cloud Optimized Dicom format.
337 lines (336 loc) • 16.3 kB
JavaScript
import { parseDicom } from 'dicom-parser';
import FileManager from '../fileManager';
import MetadataManager from '../metadataManager';
import { getFrameDetailsFromMetadata, parseWadorsURL } from './utils';
import { register } from '../dataRetrieval/register';
import constants, { Enums } from '../constants';
import { getDataRetrievalManager } from '../dataRetrieval/dataRetrievalManager';
import { CustomError } from './customClasses';
import { CustomErrorEvent } from './customClasses';
import { download, getDirectoryHandle } from '../fileAccessSystemUtils';
class CodDicomWebServer {
filePromises = {};
options = {
maxCacheSize: 4 * 1024 * 1024 * 1024,
domain: constants.url.DOMAIN,
enableLocalCache: false
};
fileManager;
metadataManager;
seriesUidFileUrls = {};
constructor(args = {}) {
const { maxCacheSize, domain, disableWorker, enableLocalCache } = args;
this.options.maxCacheSize = maxCacheSize || this.options.maxCacheSize;
this.options.domain = domain || this.options.domain;
this.options.enableLocalCache = !!enableLocalCache;
const fileStreamingScriptName = constants.dataRetrieval.FILE_STREAMING_WORKER_NAME;
const filePartialScriptName = constants.dataRetrieval.FILE_PARTIAL_WORKER_NAME;
this.fileManager = new FileManager();
this.metadataManager = new MetadataManager();
if (disableWorker) {
const dataRetrievalManager = getDataRetrievalManager();
dataRetrievalManager.setDataRetrieverMode(Enums.DataRetrieveMode.REQUEST);
}
register({ fileStreamingScriptName, filePartialScriptName });
}
setOptions = (newOptions) => {
Object.keys(newOptions).forEach((key) => {
if (newOptions[key] !== undefined) {
this.options[key] = newOptions[key];
}
});
};
getOptions = () => {
return this.options;
};
addFileUrl(seriesInstanceUID, type, url) {
if (this.seriesUidFileUrls[seriesInstanceUID]) {
this.seriesUidFileUrls[seriesInstanceUID].add({ type, url });
}
else {
this.seriesUidFileUrls[seriesInstanceUID] = new Set([{ type, url }]);
}
}
async fetchCod(wadorsUrl, headers = {}, { useSharedArrayBuffer = false, fetchType = constants.Enums.FetchType.API_OPTIMIZED } = {}) {
try {
if (!wadorsUrl) {
throw new CustomError('Url not provided');
}
const parsedDetails = parseWadorsURL(wadorsUrl, this.options.domain);
if (parsedDetails) {
const { type, bucketName, bucketPrefix, studyInstanceUID, seriesInstanceUID, sopInstanceUID, frameNumber } = parsedDetails;
const metadataJson = await this.metadataManager.getMetadata({
domain: this.options.domain,
bucketName,
bucketPrefix,
studyInstanceUID,
seriesInstanceUID
}, headers);
if (!metadataJson) {
throw new CustomError(`Metadata not found for ${wadorsUrl}`);
}
const { url: fileUrl, startByte, endByte, thumbnailUrl, isMultiframe } = getFrameDetailsFromMetadata(metadataJson, sopInstanceUID, frameNumber - 1, {
domain: this.options.domain,
bucketName,
bucketPrefix
});
switch (type) {
case Enums.RequestType.THUMBNAIL:
if (!thumbnailUrl) {
throw new CustomError(`Thumbnail not found for ${wadorsUrl}`);
}
this.addFileUrl(seriesInstanceUID, Enums.URLType.THUMBNAIL, thumbnailUrl);
return this.fetchFile(thumbnailUrl, headers, {
useSharedArrayBuffer
});
case Enums.RequestType.FRAME: {
if (!fileUrl) {
throw new CustomError('Url not found for frame');
}
let urlWithBytes = fileUrl;
if (fetchType === Enums.FetchType.BYTES_OPTIMIZED) {
urlWithBytes = `${fileUrl}?bytes=${startByte}-${endByte}`;
}
this.addFileUrl(seriesInstanceUID, Enums.URLType.FILE, fileUrl);
return this.fetchFile(urlWithBytes, headers, {
offsets: { startByte, endByte },
useSharedArrayBuffer,
fetchType
}).then((arraybuffer) => {
if (!arraybuffer?.byteLength) {
throw new CustomError('File Arraybuffer is not found');
}
if (isMultiframe) {
return arraybuffer;
}
else {
const dataSet = parseDicom(new Uint8Array(arraybuffer));
const pixelDataElement = dataSet.elements.x7fe00010;
let { dataOffset, length } = pixelDataElement;
if (pixelDataElement.hadUndefinedLength && pixelDataElement.fragments) {
({ position: dataOffset, length } = pixelDataElement.fragments[0]);
}
return arraybuffer.slice(dataOffset, dataOffset + length);
}
});
}
case Enums.RequestType.SERIES_METADATA:
case Enums.RequestType.INSTANCE_METADATA:
return this.parseMetadata(metadataJson, type, sopInstanceUID);
default:
throw new CustomError(`Unsupported request type: ${type}`);
}
}
else {
return new Promise((resolve, reject) => {
return this.fetchFile(wadorsUrl, headers, { useSharedArrayBuffer })
.then((result) => {
if (result instanceof ArrayBuffer) {
try {
const dataSet = parseDicom(new Uint8Array(result));
const seriesInstanceUID = dataSet.string('0020000e');
if (seriesInstanceUID) {
this.addFileUrl(seriesInstanceUID, Enums.URLType.OTHERS, wadorsUrl);
}
}
catch (error) {
console.warn('CodDicomWebServer.ts: There is some issue parsing the file.', error);
}
}
resolve(result);
})
.catch((error) => reject(error));
});
}
}
catch (error) {
const newError = new CustomError(`CodDicomWebServer.ts: ${error.message || 'An error occured when fetching the COD'}`);
console.error(newError);
throw newError;
}
}
async fetchFile(fileUrl, headers, { offsets, useSharedArrayBuffer = false, fetchType = constants.Enums.FetchType.API_OPTIMIZED } = {}) {
const isBytesOptimized = fetchType === Enums.FetchType.BYTES_OPTIMIZED;
const extractedFile = this.fileManager.get(fileUrl, isBytesOptimized ? undefined : offsets);
if (extractedFile) {
return new Promise((resolveRequest, rejectRequest) => {
try {
resolveRequest(extractedFile.buffer);
}
catch (error) {
rejectRequest(error);
}
});
}
const directoryHandle = this.options.enableLocalCache && (await getDirectoryHandle());
const dataRetrievalManager = getDataRetrievalManager();
const { FILE_STREAMING_WORKER_NAME, FILE_PARTIAL_WORKER_NAME } = constants.dataRetrieval;
let tarPromise;
if (!this.filePromises[fileUrl]) {
tarPromise = new Promise((resolveFile, rejectFile) => {
const FetchTypeEnum = constants.Enums.FetchType;
if (fetchType === FetchTypeEnum.API_OPTIMIZED) {
const handleFirstChunk = (evt) => {
if (evt instanceof CustomErrorEvent) {
rejectFile(evt.error);
throw evt.error;
}
const { url, position, fileArraybuffer } = evt.data;
if (url === fileUrl && fileArraybuffer) {
this.fileManager.set(url, { data: fileArraybuffer, position });
dataRetrievalManager.removeEventListener(FILE_STREAMING_WORKER_NAME, 'message', handleFirstChunk);
}
};
dataRetrievalManager.addEventListener(FILE_STREAMING_WORKER_NAME, 'message', handleFirstChunk);
dataRetrievalManager
.executeTask(FILE_STREAMING_WORKER_NAME, 'stream', {
url: fileUrl,
headers: headers,
useSharedArrayBuffer,
directoryHandle
})
.then(() => {
resolveFile();
})
.catch((error) => {
rejectFile(error);
})
.then(() => {
dataRetrievalManager.removeEventListener(FILE_STREAMING_WORKER_NAME, 'message', handleFirstChunk);
delete this.filePromises[fileUrl];
});
}
else if (fetchType === FetchTypeEnum.BYTES_OPTIMIZED && offsets) {
const { startByte, endByte } = offsets;
const bytesRemovedUrl = fileUrl.split('?bytes=')[0];
const handleSlice = (evt) => {
if (evt instanceof CustomErrorEvent) {
rejectFile(evt.error);
throw evt.error;
}
const { url, fileArraybuffer, offsets } = evt.data;
if (url === bytesRemovedUrl && offsets.startByte === startByte && offsets.endByte === endByte) {
this.fileManager.set(fileUrl, { data: fileArraybuffer, position: fileArraybuffer.length });
dataRetrievalManager.removeEventListener(FILE_PARTIAL_WORKER_NAME, 'message', handleSlice);
resolveFile();
}
};
dataRetrievalManager.addEventListener(FILE_PARTIAL_WORKER_NAME, 'message', handleSlice);
dataRetrievalManager
.executeTask(FILE_PARTIAL_WORKER_NAME, 'partial', {
url: bytesRemovedUrl,
offsets: { startByte, endByte },
headers,
directoryHandle
})
.catch((error) => {
rejectFile(error);
})
.then(() => {
dataRetrievalManager.removeEventListener(FILE_PARTIAL_WORKER_NAME, 'message', handleSlice);
delete this.filePromises[fileUrl];
});
}
else {
rejectFile(new CustomError('CodDicomWebServer.ts: Offsets is needed in bytes optimized fetching'));
}
});
this.filePromises[fileUrl] = tarPromise;
}
else {
tarPromise = this.filePromises[fileUrl];
}
return new Promise((resolveRequest, rejectRequest) => {
let requestResolved = false;
const handleChunkAppend = (evt) => {
if (evt instanceof CustomErrorEvent) {
rejectRequest(evt.message);
throw evt.error;
}
const { url, position, chunk, totalLength, isAppending } = evt.data;
if (isAppending) {
if (chunk) {
this.fileManager.append(url, chunk, position);
}
else {
this.fileManager.setPosition(url, position);
}
}
else {
// The full empty file including with first chunk have been stored to fileManager
// by the worker listener in the file promise.
// So, we check whether the cache exceeded the limit here.
if (this.fileManager.getTotalSize() > this.options.maxCacheSize) {
this.fileManager.decacheNecessaryBytes(url, totalLength);
}
}
if (!requestResolved && url === fileUrl && offsets && position > offsets.endByte) {
try {
const file = this.fileManager.get(url, offsets);
requestResolved = true;
resolveRequest(file?.buffer);
}
catch (error) {
rejectRequest(error);
}
}
};
if (offsets && !isBytesOptimized) {
dataRetrievalManager.addEventListener(FILE_STREAMING_WORKER_NAME, 'message', handleChunkAppend);
}
tarPromise
.then(() => {
if (!requestResolved) {
if (this.fileManager.getPosition(fileUrl)) {
const file = this.fileManager.get(fileUrl, isBytesOptimized ? undefined : offsets);
requestResolved = true;
resolveRequest(file?.buffer);
}
else {
rejectRequest(new CustomError(`File - ${fileUrl} not found`));
}
}
})
.catch((error) => {
rejectRequest(error);
})
.then(() => {
dataRetrievalManager.removeEventListener(FILE_STREAMING_WORKER_NAME, 'message', handleChunkAppend);
});
});
}
downloadSeriesFile(seriesInstanceUID) {
const seriesFileURL = Array.from(this.seriesUidFileUrls[seriesInstanceUID]).find(({ url, type }) => type === Enums.URLType.FILE && url.endsWith('.tar'))?.url;
if (seriesFileURL) {
const seriesArrayBuffer = this.fileManager.get(seriesFileURL);
return download(seriesFileURL.split('/').at(-1), seriesArrayBuffer);
}
return false;
}
delete(seriesInstanceUID) {
const fileUrls = this.seriesUidFileUrls[seriesInstanceUID];
if (fileUrls) {
fileUrls.forEach(({ url }) => {
this.fileManager.remove(url);
});
}
delete this.seriesUidFileUrls[seriesInstanceUID];
}
deleteAll() {
Object.values(this.seriesUidFileUrls).forEach((fileUrls) => {
fileUrls.forEach(({ url }) => {
this.fileManager.remove(url);
});
});
this.seriesUidFileUrls = {};
}
parseMetadata(metadata, type, sopInstanceUID) {
if (type === Enums.RequestType.INSTANCE_METADATA) {
return Object.entries(metadata.cod.instances).find(([key, instance]) => key === sopInstanceUID)?.[1].metadata;
}
else {
return Object.values(metadata.cod.instances).map((instance) => instance.metadata);
}
}
}
export default CodDicomWebServer;