diff --git a/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts b/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts index 9ba37f8c..28001611 100644 --- a/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts +++ b/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts @@ -2,11 +2,12 @@ import { RequestPool } from '@/src/core/streaming/requestPool'; import { CachedStreamFetcher, + sliceChunks, StopSignal, } from '@/src/core/streaming/cachedStreamFetcher'; import { describe, expect, it } from 'vitest'; -describe('ResumableFetcher', () => { +describe('CachedStreamFetcher', () => { it('should support stopping and resuming', async () => { const pool = new RequestPool(); const fetcher = new CachedStreamFetcher( @@ -51,3 +52,24 @@ describe('ResumableFetcher', () => { fetcher.close(); }); }); + +describe('sliceChunks', () => { + it('should work', () => { + expect(sliceChunks([new Uint8Array([1, 2, 3])], 0)).toEqual([]); + expect(sliceChunks([new Uint8Array([1, 2, 3])], 1)).toEqual([ + new Uint8Array([1]), + ]); + expect(sliceChunks([new Uint8Array([1])], 1)).toEqual([ + new Uint8Array([1]), + ]); + expect(sliceChunks([new Uint8Array([1, 2])], 1)).toEqual([ + new Uint8Array([1]), + ]); + expect(sliceChunks([new Uint8Array([1, 2])], 3)).toEqual([ + new Uint8Array([1, 2]), + ]); + expect( + sliceChunks([new Uint8Array([1, 2]), new Uint8Array([3, 4])], 3) + ).toEqual([new Uint8Array([1, 2]), new Uint8Array([3])]); + }); +}); diff --git a/src/core/streaming/cachedStreamFetcher.ts b/src/core/streaming/cachedStreamFetcher.ts index f98a63e5..e94d28fb 100644 --- a/src/core/streaming/cachedStreamFetcher.ts +++ b/src/core/streaming/cachedStreamFetcher.ts @@ -5,6 +5,10 @@ import { HttpNotFound, } from '@/src/core/streaming/httpCodes'; import { Fetcher, FetcherInit } from '@/src/core/streaming/types'; +import { + ContentRange, + parseContentRangeHeader, +} from '@/src/utils/parseContentRangeHeader'; import { Maybe } from '@/src/types'; type FetchFunction = typeof fetch; @@ -17,6 +21,46 @@ export interface CachedStreamFetcherRequestInit extends RequestInit { export const StopSignal = Symbol('StopSignal'); +export function sliceChunks(chunks: Uint8Array[], start: number) { + const newChunks: Uint8Array[] = []; + let size = 0; + for (let i = 0; i < chunks.length && size < start; i++) { + const chunk = chunks[i]; + if (size + chunk.length > start) { + const offset = start - size; + const newChunk = chunk.slice(0, offset); + newChunks.push(newChunk); + size += newChunk.length; + } else { + newChunks.push(chunk); + size += chunk.length; + } + } + return newChunks; +} + +/** + * Infers the content range from a full content length and a remaining content length. + * + * The remaining length is expected to be the length of the remaining bytes to + * be downloaded. + */ +function inferContentRange( + remainingLength: number | null, + totalLength: number | null +): ContentRange { + if (remainingLength == null || totalLength == null) + return { type: 'empty-range' }; + + if (remainingLength > totalLength) { + return { type: 'invalid-range' }; + } + + const start = totalLength - remainingLength; + const end = totalLength - 1; + return { type: 'range', start, end, length: totalLength }; +} + /** * A cached stream fetcher that caches a URI stream. * @@ -87,13 +131,29 @@ export class CachedStreamFetcher implements Fetcher { this.contentType = response.headers.get('content-type') ?? ''; - if (this.size === 0 && response.headers.has('content-length')) { - this.contentLength = Number(response.headers.get('content-length')!); + let remainingContentLength: number | null = null; + if (response.headers.has('content-length')) { + remainingContentLength = Number(response.headers.get('content-length')); + } + + // set this.contentLength if we didn't submit a partial request + if ( + this.size === 0 && + this.contentLength == null && + remainingContentLength != null + ) { + this.contentLength = remainingContentLength; } if (!response.body) throw new Error('Did not receive a response body'); - const noMoreContent = response.headers.get('content-length') === '0'; + const noMoreContent = remainingContentLength === 0; + // Content-Range needs to be in Access-Control-Expose-Headers. If not, then + // try to infer based on the remaining content length and the total content + // length. + const contentRange = response.headers.has('content-range') + ? parseContentRangeHeader(response.headers.get('content-range')) + : inferContentRange(remainingContentLength, this.contentLength); const rangeNotSatisfiable = response.status === HTTP_STATUS_REQUESTED_RANGE_NOT_SATISFIABLE; @@ -109,7 +169,19 @@ export class CachedStreamFetcher implements Fetcher { throw new HttpNotFound(this.request.toString()); } - if (!noMoreContent && response.status !== HTTP_STATUS_PARTIAL_CONTENT) { + if (response.status === HTTP_STATUS_PARTIAL_CONTENT) { + if (contentRange.type === 'invalid-range') + throw new Error('Invalid content-range header'); + if (contentRange.type === 'unsatisfied-range') + throw new Error('Range could not be satisfied'); + + let start: number = 0; + if (contentRange.type === 'range') { + start = contentRange.start; + } + + this.chunks = sliceChunks(this.chunks, start); + } else if (!noMoreContent) { this.chunks = []; } diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 840eb59b..271a3fbe 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -238,9 +238,12 @@ export default class DicomChunkImage implements ChunkImage { const chunk = this.chunks[chunkIndex]; if (!chunk.dataBlob) throw new Error('Chunk does not have data'); - const result = await readImage(new File([chunk.dataBlob], 'file.dcm'), { - webWorker: getWorker(), - }); + const result = await readImage( + new File([chunk.dataBlob], `file-${chunkIndex}.dcm`), + { + webWorker: getWorker(), + } + ); if (!result.image.data) throw new Error('No data read from chunk'); diff --git a/src/utils/__tests__/parseContentRangeHeader.spec.ts b/src/utils/__tests__/parseContentRangeHeader.spec.ts new file mode 100644 index 00000000..fe397107 --- /dev/null +++ b/src/utils/__tests__/parseContentRangeHeader.spec.ts @@ -0,0 +1,46 @@ +import { parseContentRangeHeader } from '@/src/utils/parseContentRangeHeader'; +import { describe, expect, it } from 'vitest'; + +describe('parseContentRangeHeader', () => { + it('should handle valid ranges', () => { + let range = parseContentRangeHeader('bytes 0-1/123'); + expect(range.type).toEqual('range'); + if (range.type !== 'range') return; // ts can't narrow on expect() + + expect(range.start).toEqual(0); + expect(range.end).toEqual(1); + expect(range.length).toEqual(123); + + range = parseContentRangeHeader('bytes 2-5/*'); + expect(range.type).toEqual('range'); + if (range.type !== 'range') return; // ts can't narrow on expect() + + expect(range.start).toEqual(2); + expect(range.end).toEqual(5); + expect(range.length).to.be.null; + }); + + it('should handle unsatisfied ranges', () => { + const range = parseContentRangeHeader('bytes */12'); + expect(range.type).toEqual('unsatisfied-range'); + if (range.type !== 'unsatisfied-range') return; // ts can't narrow on expect() + + expect(range.length).toEqual(12); + }); + + it('should handle invalid ranges', () => { + [ + '', + 'bytes', + 'bytes */*', + 'byte 0-1/2', + 'bytes 1-0/2', + 'bytes 0-1/1', + 'bytes 1-3/2', + 'bytes 1-/2', + 'bytes -1/2', + ].forEach((range) => { + expect(parseContentRangeHeader(range).type).toEqual('invalid-range'); + }); + }); +}); diff --git a/src/utils/parseContentRangeHeader.ts b/src/utils/parseContentRangeHeader.ts new file mode 100644 index 00000000..80db6abb --- /dev/null +++ b/src/utils/parseContentRangeHeader.ts @@ -0,0 +1,40 @@ +const CONTENT_RANGE_REGEXP = + /^bytes (?(?\d+)-(?\d+)|\*)\/(?\d+|\*)$/; + +export type ContentRange = + | { type: 'empty-range' } + | { type: 'invalid-range' } + | { type: 'unsatisfied-range'; length: number } + | { type: 'range'; start: number; end: number; length: number | null }; + +/** + * Parses a Content-Range header. + * + * Only supports bytes ranges. + * @param headerValue + * @returns + */ +export function parseContentRangeHeader( + headerValue: string | null +): ContentRange { + if (!headerValue) return { type: 'empty-range' }; + + const match = CONTENT_RANGE_REGEXP.exec(headerValue); + const groups = match?.groups; + if (!groups) return { type: 'invalid-range' }; + + const length = groups.length === '*' ? null : parseInt(groups.length, 10); + + if (groups.range === '*') { + if (length === null) return { type: 'invalid-range' }; + return { type: 'unsatisfied-range', length }; + } + + const start = parseInt(groups.start, 10); + const end = parseInt(groups.end, 10); + + if (end < start || (length !== null && length <= end)) + return { type: 'invalid-range' }; + + return { type: 'range', start, end, length }; +}