Skip to content

Commit

Permalink
fix(cachedStreamFetcher): handle Content-Range
Browse files Browse the repository at this point in the history
Support handling content-range headers if exposed. If not, then
content-range is inferred based on the content-length headers.
  • Loading branch information
floryst committed Aug 1, 2024
1 parent 82544ed commit 0bfe9d2
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 8 deletions.
24 changes: 23 additions & 1 deletion src/core/streaming/__tests__/cachedStreamFetcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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])]);
});
});
80 changes: 76 additions & 4 deletions src/core/streaming/cachedStreamFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -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;

Expand All @@ -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 = [];
}

Expand Down
9 changes: 6 additions & 3 deletions src/core/streaming/dicomChunkImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
46 changes: 46 additions & 0 deletions src/utils/__tests__/parseContentRangeHeader.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
40 changes: 40 additions & 0 deletions src/utils/parseContentRangeHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const CONTENT_RANGE_REGEXP =
/^bytes (?<range>(?<start>\d+)-(?<end>\d+)|\*)\/(?<length>\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 };
}

0 comments on commit 0bfe9d2

Please sign in to comment.