cod-dicomweb-server 1.0.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.
@@ -0,0 +1,176 @@
1
+ import constants, { Enums } from '../constants';
2
+ import type {
3
+ JsonMetadata,
4
+ MetadataUrlCreationParams,
5
+ ParsedWadoRsUrlDetails,
6
+ } from '../types';
7
+
8
+ export function parseWadorsURL(
9
+ url: string,
10
+ domain: string,
11
+ ): ParsedWadoRsUrlDetails | undefined {
12
+ if (!url.includes(constants.url.URL_VALIDATION_STRING)) {
13
+ return;
14
+ }
15
+
16
+ const filePath = url.split(domain + '/')[1];
17
+
18
+ const prefix = filePath.split('/studies')[0];
19
+ const prefixParts = prefix.split('/');
20
+
21
+ const bucketName = prefixParts[0];
22
+ const bucketPrefix = prefixParts.slice(1).join('/');
23
+
24
+ const imagePath = filePath.split(prefix + '/')[1];
25
+ const imageParts = imagePath.split('/');
26
+
27
+ const studyInstanceUID = imageParts[1];
28
+ const seriesInstanceUID = imageParts[3];
29
+ let sopInstanceUID = '',
30
+ frameNumber = 1,
31
+ type: Enums.RequestType;
32
+
33
+ switch (true) {
34
+ case imageParts.includes('thumbnail'):
35
+ type = Enums.RequestType.THUMBNAIL;
36
+ break;
37
+ case imageParts.includes('metadata'):
38
+ if (imageParts.includes('instances')) {
39
+ sopInstanceUID = imageParts[5];
40
+ type = Enums.RequestType.INSTANCE_METADATA;
41
+ } else {
42
+ type = Enums.RequestType.SERIES_METADATA;
43
+ }
44
+ break;
45
+ case imageParts.includes('frames'):
46
+ sopInstanceUID = imageParts[5];
47
+ frameNumber = +(imageParts[7] || frameNumber);
48
+ type = Enums.RequestType.FRAME;
49
+ break;
50
+ default:
51
+ throw new Error('Invalid type of request');
52
+ }
53
+
54
+ return {
55
+ type,
56
+ bucketName,
57
+ bucketPrefix,
58
+ studyInstanceUID,
59
+ seriesInstanceUID,
60
+ sopInstanceUID,
61
+ frameNumber,
62
+ };
63
+ }
64
+
65
+ export function getFrameDetailsFromMetadata(
66
+ seriesMetadata: JsonMetadata,
67
+ sopInstanceUID: string,
68
+ frameIndex: number,
69
+ bucketDetails: { domain: string; bucketName: string; bucketPrefix: string },
70
+ ): {
71
+ url: string;
72
+ startByte: number;
73
+ endByte: number;
74
+ thumbnailUrl: string;
75
+ isMultiframe: boolean;
76
+ } {
77
+ if (
78
+ !seriesMetadata ||
79
+ !seriesMetadata.cod?.instances ||
80
+ !seriesMetadata.thumbnail
81
+ ) {
82
+ throw new Error('Invalid seriesMetadata provided.');
83
+ }
84
+
85
+ if (!sopInstanceUID) {
86
+ throw new Error('SOP Instance UID is required.');
87
+ }
88
+
89
+ if (frameIndex === null || frameIndex === undefined) {
90
+ throw new Error('Frame index is required.');
91
+ }
92
+
93
+ const instanceFound = Object.values(seriesMetadata.cod.instances).find(
94
+ (instance) => instance.metadata['00080018']?.Value?.[0] === sopInstanceUID,
95
+ );
96
+
97
+ if (!instanceFound) {
98
+ throw new Error(
99
+ `Instance with SOPInstanceUID ${sopInstanceUID} not found.`,
100
+ );
101
+ }
102
+
103
+ const { domain, bucketName, bucketPrefix } = bucketDetails;
104
+ const { url, uri, headers: offsetHeaders, offset_tables } = instanceFound;
105
+ const modifiedUrl = handleUrl(url || uri, domain, bucketName, bucketPrefix);
106
+
107
+ const { CustomOffsetTable, CustomOffsetTableLengths } = offset_tables || {};
108
+
109
+ let sliceStart: number | undefined,
110
+ sliceEnd: number | undefined,
111
+ isMultiframe = false;
112
+ if (CustomOffsetTable?.length && CustomOffsetTableLengths?.length) {
113
+ sliceStart = CustomOffsetTable[frameIndex];
114
+ sliceEnd = sliceStart + CustomOffsetTableLengths[frameIndex];
115
+ isMultiframe = true;
116
+ }
117
+
118
+ const { start_byte: fileStartByte, end_byte: fileEndByte } = offsetHeaders;
119
+
120
+ const startByte =
121
+ sliceStart !== undefined ? fileStartByte + sliceStart : fileStartByte;
122
+ const endByte =
123
+ sliceEnd !== undefined ? fileStartByte + sliceEnd : fileEndByte;
124
+
125
+ const thumbnailGsUtilUri = seriesMetadata.thumbnail.uri;
126
+ const thumbnailUrl = `${domain}/${thumbnailGsUtilUri.split('gs://')[1]}`;
127
+
128
+ return {
129
+ url: modifiedUrl,
130
+ startByte,
131
+ endByte,
132
+ thumbnailUrl,
133
+ isMultiframe,
134
+ };
135
+ }
136
+
137
+ export function handleUrl(
138
+ url: string,
139
+ domain: string,
140
+ bucketName: string,
141
+ bucketPrefix: string,
142
+ ): string {
143
+ let modifiedUrl = url;
144
+
145
+ const matchingExtension = constants.url.FILE_EXTENSIONS.find((extension) =>
146
+ url.includes(extension),
147
+ );
148
+
149
+ if (matchingExtension) {
150
+ const fileParts = url.split(matchingExtension);
151
+ modifiedUrl = fileParts[0] + matchingExtension;
152
+ }
153
+
154
+ const filePath = modifiedUrl.split('studies/')[1];
155
+ modifiedUrl = `${domain}/${bucketName}/${bucketPrefix ? bucketPrefix + '/' : ''}studies/${filePath}`;
156
+
157
+ return modifiedUrl;
158
+ }
159
+
160
+ export function createMetadataJsonUrl(
161
+ params: MetadataUrlCreationParams,
162
+ ): string | undefined {
163
+ const {
164
+ domain = constants.url.DOMAIN,
165
+ bucketName,
166
+ bucketPrefix,
167
+ studyInstanceUID,
168
+ seriesInstanceUID,
169
+ } = params;
170
+
171
+ if (!bucketName || !bucketPrefix || !studyInstanceUID || !seriesInstanceUID) {
172
+ return;
173
+ }
174
+
175
+ return `${domain}/${bucketName}/${bucketPrefix}/studies/${studyInstanceUID}/series/${seriesInstanceUID}/metadata.json`;
176
+ }
@@ -0,0 +1,18 @@
1
+ export enum FetchType {
2
+ /**
3
+ * Fetch only the part of the file according to the offsets provided.
4
+ */
5
+ BYTES_OPTIMIZED,
6
+ /**
7
+ * Stream the file and returns the part of the file if offsets are provided.
8
+ * Or returns the whole file.
9
+ */
10
+ API_OPTIMIZED,
11
+ }
12
+
13
+ export enum RequestType {
14
+ FRAME,
15
+ THUMBNAIL,
16
+ SERIES_METADATA,
17
+ INSTANCE_METADATA,
18
+ }
@@ -0,0 +1,8 @@
1
+ import * as Enums from './enums';
2
+ import * as url from './url';
3
+ import * as worker from './worker';
4
+
5
+ const constants = { Enums, url, worker };
6
+
7
+ export { Enums, url, worker };
8
+ export default constants;
@@ -0,0 +1,5 @@
1
+ export const DOMAIN = 'https://storage.googleapis.com';
2
+
3
+ export const FILE_EXTENSIONS = ['.tar', '.zip'];
4
+
5
+ export const URL_VALIDATION_STRING = '/dicomweb/';
@@ -0,0 +1,4 @@
1
+ export const FILE_PARTIAL_WORKER_NAME = 'filePartial';
2
+ export const FILE_STREAMING_WORKER_NAME = 'fileStreaming';
3
+
4
+ export const THRESHOLD = 10000;
@@ -0,0 +1,84 @@
1
+ import type { FileManagerOptions } from './types';
2
+ import { getWebWorkerManager } from './webWorker/workerManager';
3
+
4
+ class FileManager {
5
+ private files: Record<string, { data: Uint8Array; position: number }> = {};
6
+ private fileStreamingWorkerName: string;
7
+
8
+ constructor({ fileStreamingWorkerName }: FileManagerOptions) {
9
+ this.fileStreamingWorkerName = fileStreamingWorkerName;
10
+ }
11
+
12
+ set(url: string, file: { data: Uint8Array; position: number }): void {
13
+ this.files[url] = file;
14
+ }
15
+
16
+ get(
17
+ url: string,
18
+ offsets?: { startByte: number; endByte: number },
19
+ ): Uint8Array | null {
20
+ if (
21
+ !this.files[url] ||
22
+ (offsets && this.files[url].position <= offsets.endByte)
23
+ ) {
24
+ return null;
25
+ }
26
+
27
+ return offsets
28
+ ? this.files[url].data.slice(offsets.startByte, offsets.endByte)
29
+ : this.files[url].data;
30
+ }
31
+
32
+ setPosition(url: string, position: number): void {
33
+ if (this.files[url]) {
34
+ this.files[url].position = position;
35
+ }
36
+ }
37
+
38
+ getPosition(url: string): number {
39
+ return this.files[url]?.position;
40
+ }
41
+
42
+ append(url: string, chunk: Uint8Array, position: number): void {
43
+ if (this.files[url] && position) {
44
+ this.files[url].data.set(chunk, position - chunk.length);
45
+ this.setPosition(url, position);
46
+ }
47
+ }
48
+
49
+ getTotalSize(): number {
50
+ return Object.entries(this.files).reduce((total, [url, { position }]) => {
51
+ return url.includes('?bytes=') ? total : total + position;
52
+ }, 0);
53
+ }
54
+
55
+ remove(url: string): void {
56
+ const removedSize = this.getPosition(url);
57
+ delete this.files[url];
58
+
59
+ if (url.includes('?bytes=')) {
60
+ return;
61
+ }
62
+
63
+ const workerManager = getWebWorkerManager();
64
+ workerManager.executeTask(
65
+ this.fileStreamingWorkerName,
66
+ 'decreaseFetchedSize',
67
+ removedSize,
68
+ );
69
+ }
70
+
71
+ purge(): void {
72
+ const totalSize = this.getTotalSize();
73
+ this.files = {};
74
+
75
+ const workerManager = getWebWorkerManager();
76
+ workerManager.executeTask(
77
+ this.fileStreamingWorkerName,
78
+ 'decreaseFetchedSize',
79
+ totalSize,
80
+ );
81
+ }
82
+ }
83
+
84
+ export default FileManager;
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta http-equiv="X-UA-Compatible" content="ie=edge"/><link rel="icon" href="/favicon.ico" type="image/x-icon"/><link rel="shortcut icon" href="/favicon.ico" type="image/x-icon"/><title></title></head><body><noscript><strong>We're sorry but doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="root" class="root"></div><script defer="defer" src="/assets/js/main.bd27b3d8a119b2e0661f.js"></script></body></html>
package/dist/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { FetchType } from './constants/enums';
2
+ import CodDicomWebServer from './classes/CodDicomWebServer';
3
+
4
+ export { FetchType, CodDicomWebServer };
5
+ export default { FetchType, CodDicomWebServer };
@@ -0,0 +1,32 @@
1
+ import { createMetadataJsonUrl } from './classes/utils';
2
+ import type { JsonMetadata, MetadataUrlCreationParams } from './types';
3
+
4
+ const metadata: Record<string, JsonMetadata> = {};
5
+
6
+ export async function getMetadata(
7
+ params: MetadataUrlCreationParams,
8
+ headers: Record<string, string>,
9
+ ): Promise<JsonMetadata | null> {
10
+ const url = createMetadataJsonUrl(params);
11
+
12
+ if (!url) {
13
+ throw new Error('Error creating metadata json url');
14
+ }
15
+
16
+ if (metadata[url]) {
17
+ return metadata[url];
18
+ }
19
+
20
+ try {
21
+ const response = await fetch(url, { headers });
22
+ if (!response.ok) {
23
+ throw new Error(`Failed to fetch metadata: ${response.statusText}`);
24
+ }
25
+ const data = await response.json();
26
+ metadata[url] = data;
27
+ return data;
28
+ } catch (error) {
29
+ console.error(error);
30
+ throw error;
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ type CodDicomWebServerOptions = {
2
+ [key: string]: number | string;
3
+ maxWorkerFetchSize: number;
4
+ domain: string;
5
+ };
6
+
7
+ export type { CodDicomWebServerOptions };
@@ -0,0 +1,3 @@
1
+ type FileManagerOptions = { fileStreamingWorkerName: string };
2
+
3
+ export type { FileManagerOptions };
@@ -0,0 +1,7 @@
1
+ export type * from './codDicomWebServerOptions';
2
+ export type * from './fileManagerOptions';
3
+ export type * from './metadata';
4
+ export type * from './metadataUrlCreationParams';
5
+ export type * from './parsedWadoRsUrlDetails';
6
+ export type * from './requestOptions';
7
+ export type * from './workerCustomMessageEvents';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Metadata format stored in the metadata.json
3
+ */
4
+ type JsonMetadata = {
5
+ deid_study_uid: string;
6
+ deid_series_uid: string;
7
+ cod: {
8
+ instances: Record<
9
+ string,
10
+ {
11
+ metadata: InstanceMetadata;
12
+ // The metadata will either have url or uri
13
+ uri: string;
14
+ url: string;
15
+ headers: { start_byte: number; end_byte: number };
16
+ offset_tables: {
17
+ CustomOffsetTable?: number[];
18
+ CustomOffsetTableLengths?: number[];
19
+ };
20
+ crc32c: string;
21
+ size: number;
22
+ original_path: string;
23
+ dependencies: string[];
24
+ diff_hash_dupe_paths: [string];
25
+ version: string;
26
+ modified_datetime: string;
27
+ }
28
+ >;
29
+ };
30
+ thumbnail: {
31
+ version: string;
32
+ uri: string;
33
+ thumbnail_index_to_instance_frame: [string, number][];
34
+ instances: Record<
35
+ string,
36
+ {
37
+ frames: {
38
+ thumbnail_index: number;
39
+ anchors: {
40
+ original_size: { width: number; height: number };
41
+ thumbnail_upper_left: { row: number; col: number };
42
+ thumbnail_bottom_right: { row: number; col: number };
43
+ };
44
+ }[];
45
+ }
46
+ >;
47
+ };
48
+ };
49
+
50
+ type InstanceMetadata = Record<
51
+ string,
52
+ { vr: string; Value?: unknown[]; BulkDataURI?: string; InlineBinary?: string }
53
+ >;
54
+
55
+ type SeriesMetadata = InstanceMetadata[];
56
+
57
+ export type { InstanceMetadata, SeriesMetadata, JsonMetadata };
@@ -0,0 +1,9 @@
1
+ type MetadataUrlCreationParams = {
2
+ domain?: string;
3
+ bucketName: string;
4
+ bucketPrefix: string;
5
+ studyInstanceUID: string;
6
+ seriesInstanceUID: string;
7
+ };
8
+
9
+ export type { MetadataUrlCreationParams };
@@ -0,0 +1,13 @@
1
+ import type { Enums } from '../constants';
2
+
3
+ type ParsedWadoRsUrlDetails = {
4
+ type: Enums.RequestType;
5
+ bucketName: string;
6
+ bucketPrefix: string;
7
+ studyInstanceUID: string;
8
+ seriesInstanceUID: string;
9
+ sopInstanceUID: string;
10
+ frameNumber: number;
11
+ };
12
+
13
+ export type { ParsedWadoRsUrlDetails };
@@ -0,0 +1,12 @@
1
+ import { Enums } from '../constants';
2
+
3
+ type CODRequestOptions = {
4
+ useSharedArrayBuffer?: boolean;
5
+ fetchType?: Enums.FetchType;
6
+ };
7
+
8
+ type FileRequestOptions = CODRequestOptions & {
9
+ offsets?: { startByte: number; endByte: number };
10
+ };
11
+
12
+ export type { CODRequestOptions, FileRequestOptions };
@@ -0,0 +1,11 @@
1
+ interface FileStreamingMessageEvent extends MessageEvent {
2
+ data: {
3
+ url: string;
4
+ position: number;
5
+ chunk?: Uint8Array;
6
+ isAppending?: boolean;
7
+ fileArraybuffer?: Uint8Array;
8
+ };
9
+ }
10
+
11
+ export type { FileStreamingMessageEvent };
@@ -0,0 +1,33 @@
1
+ import { getWebWorkerManager } from './workerManager';
2
+
3
+ export function registerWorkers(
4
+ workerNames: {
5
+ fileStreamingWorkerName: string;
6
+ filePartialWorkerName: string;
7
+ },
8
+ maxFetchSize: number,
9
+ ): void {
10
+ const { fileStreamingWorkerName, filePartialWorkerName } = workerNames;
11
+ const workerManager = getWebWorkerManager();
12
+
13
+ // fileStreaming worker
14
+ const streamingWorkerFn = (): Worker =>
15
+ new Worker(new URL('./workers/fileStreaming.ts', import.meta.url), {
16
+ name: fileStreamingWorkerName,
17
+ });
18
+
19
+ workerManager.registerWorker(fileStreamingWorkerName, streamingWorkerFn);
20
+ workerManager.executeTask(
21
+ fileStreamingWorkerName,
22
+ 'setMaxFetchSize',
23
+ maxFetchSize,
24
+ );
25
+
26
+ // filePartial worker
27
+ const partialWorkerFn = (): Worker =>
28
+ new Worker(new URL('./workers/filePartial.ts', import.meta.url), {
29
+ name: filePartialWorkerName,
30
+ });
31
+
32
+ workerManager.registerWorker(filePartialWorkerName, partialWorkerFn);
33
+ }
@@ -0,0 +1,83 @@
1
+ import { type Remote, wrap } from 'comlink';
2
+
3
+ import type { FileStreamingMessageEvent } from '../types';
4
+
5
+ class WebWorkerManager {
6
+ private workerRegistry: Record<
7
+ string,
8
+ { instance: Remote<Worker>; nativeWorker: Worker }
9
+ > = {};
10
+
11
+ public registerWorker(name: string, workerFn: () => Worker): void {
12
+ try {
13
+ const worker: Worker = workerFn();
14
+ if (!worker) {
15
+ return;
16
+ }
17
+
18
+ this.workerRegistry[name] = {
19
+ instance: wrap(worker),
20
+ nativeWorker: worker,
21
+ };
22
+ } catch (error) {
23
+ console.warn(error);
24
+ }
25
+ }
26
+
27
+ public async executeTask(
28
+ workerName: string,
29
+ taskName: string,
30
+ options: Record<string, unknown> | unknown,
31
+ ): Promise<void | ArrayBufferLike> {
32
+ const worker = this.workerRegistry[workerName]?.instance;
33
+ if (!worker) {
34
+ throw new Error(`Worker ${workerName} not registered`);
35
+ }
36
+
37
+ try {
38
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
39
+ // @ts-ignore
40
+ return await worker[taskName](options);
41
+ } catch (error) {
42
+ console.error(
43
+ `Error executing task "${taskName}" on worker "${workerName}":`,
44
+ error,
45
+ );
46
+ throw new Error(`Task "${taskName}" failed: ${(error as Error).message}`);
47
+ }
48
+ }
49
+
50
+ public addEventListener(
51
+ workerName: string,
52
+ eventType: keyof WorkerEventMap,
53
+ listener: (evt: FileStreamingMessageEvent | ErrorEvent) => unknown,
54
+ ): void {
55
+ const worker = this.workerRegistry[workerName];
56
+ if (!worker) {
57
+ console.error(`Worker type '${workerName}' is not registered.`);
58
+ return;
59
+ }
60
+
61
+ worker.nativeWorker.addEventListener(eventType, listener);
62
+ }
63
+
64
+ public removeEventListener(
65
+ workerName: string,
66
+ eventType: keyof WorkerEventMap,
67
+ listener: (evt: FileStreamingMessageEvent | ErrorEvent) => unknown,
68
+ ): void {
69
+ const worker = this.workerRegistry[workerName];
70
+ if (!worker) {
71
+ console.error(`Worker type '${workerName}' is not registered.`);
72
+ return;
73
+ }
74
+
75
+ worker.nativeWorker.removeEventListener(eventType, listener);
76
+ }
77
+ }
78
+
79
+ const webWorkerManager = new WebWorkerManager();
80
+
81
+ export function getWebWorkerManager(): WebWorkerManager {
82
+ return webWorkerManager;
83
+ }
@@ -0,0 +1,20 @@
1
+ import { expose } from 'comlink';
2
+
3
+ const filePartial = {
4
+ async partial(args: {
5
+ url: string;
6
+ headers?: Record<string, string>;
7
+ }): Promise<ArrayBufferLike | Error> {
8
+ const { url, headers } = args;
9
+
10
+ return fetch(url, { headers })
11
+ .then((response) => response.arrayBuffer())
12
+ .catch((error) => {
13
+ throw new Error(
14
+ 'filePartial.ts: Error when fetching file: ' + error?.message,
15
+ );
16
+ });
17
+ },
18
+ };
19
+
20
+ expose(filePartial);