Skip to content

Commit

Permalink
feat(ranges): add ByteRange implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
TomokiMiyauci committed Mar 10, 2023
1 parent 9d905ee commit 1741e1d
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 0 deletions.
127 changes: 127 additions & 0 deletions ranges/bytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import {
type IntRange,
isIntRange,
isNumber,
RangeHeader,
RepresentationHeader,
type SuffixRange,
toHashString,
} from "../deps.ts";
import type {
IsSatisfiableContext,
PartialContent,
PartialContext,
Range,
} from "../types.ts";
import { RangeUnit, Specifier } from "../util.ts";
import { type InclRange, multipartByteranges } from "./utils.ts";

interface Options {
readonly boundary?: BoundaryCallback;
}

export class BytesRange
implements Range<Specifier.SuffixRange | Specifier.IntRange> {
constructor(options?: Options) {
this.#boundary = options?.boundary ?? getBoundary;
}

unit = RangeUnit.Bytes;
specifiers = [Specifier.SuffixRange, Specifier.IntRange] as const;
#boundary: BoundaryCallback;

getSatisfiable(
context: IsSatisfiableContext<IntRange | SuffixRange>,
): (IntRange | SuffixRange)[] {
return context.rangeSet.filter((rangeSpec) =>
isSatisfiable(rangeSpec, context.content.byteLength)
);
}

async getPartial(
context: PartialContext<IntRange | SuffixRange>,
): Promise<PartialContent> {
const { rangeSet, content, contentType } = context;
const size = content.byteLength;
const ranges = rangeSet.map((rangeSpec) =>
rangeSpec2InclRange(rangeSpec, size)
) as [InclRange, ...InclRange[]];

if (ranges.length === 1) {
const byteRange = ranges[0];
const partialBody = content.slice(
byteRange.firstPos,
byteRange.lastPos + 1,
);
const contentRange =
`bytes ${byteRange.firstPos}-${byteRange.lastPos}/${size}`;
const headers = new Headers({ [RangeHeader.ContentRange]: contentRange });

return { content: partialBody, headers };
}

const boundary = await this.#boundary(content);
const newContentType = `multipart/byteranges; boundary=${boundary}`;
const multipart = multipartByteranges({
content,
contentType,
ranges,
boundary,
});
const headers = new Headers({
[RepresentationHeader.ContentType]: newContentType,
});

return { content: multipart, headers };
}
}

export function isSatisfiable(
rangeSpec: IntRange | SuffixRange,
contentLength: number,
): boolean {
if (isIntRange(rangeSpec)) {
if (!contentLength) return false;

return rangeSpec.firstPos < contentLength;
}

return !!rangeSpec.suffixLength;
}

export function rangeSpec2InclRange(
rangeSpec: IntRange | SuffixRange,
completeLength: number,
): InclRange {
if (isIntRange(rangeSpec)) {
const lastPos = isNumber(rangeSpec.lastPos)
? completeLength < rangeSpec.lastPos
? completeLength - 1
: rangeSpec.lastPos
: completeLength - 1;

return { firstPos: rangeSpec.firstPos, lastPos };
}

if (completeLength < rangeSpec.suffixLength) {
return { firstPos: 0, lastPos: completeLength - 1 };
}

return {
firstPos: completeLength - rangeSpec.suffixLength,
lastPos: completeLength - 1,
};
}

export interface BoundaryCallback {
(content: ArrayBuffer): string | Promise<string>;
}

export async function getBoundary(content: ArrayBuffer): Promise<string> {
const buffer = await crypto.subtle.digest("sha-1", content);

return toHashString(buffer);
}
56 changes: 56 additions & 0 deletions ranges/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import { concat, isString } from "../deps.ts";

export interface InclRange {
readonly firstPos: number;
readonly lastPos: number;
}

interface Args {
readonly content: ArrayBuffer;
readonly contentType: string;
readonly ranges: readonly InclRange[];
readonly boundary: string;
}

export function multipartByteranges(args: Args): Uint8Array {
const { content, contentType, ranges, boundary } = args;
const size = content.byteLength;
const boundaryDelimiter = "--" + boundary;
const endDelimiter = boundaryDelimiter + "--";
const encoder = new TextEncoder();
const contents = ranges
.map(bodyParts)
.flat()
.map((buffer) => new Uint8Array(buffer))
.concat(encoder.encode(endDelimiter));

return join(contents, encoder.encode("\n"));

function bodyParts(range: InclRange): ArrayBuffer[] {
return [
boundaryDelimiter,
`Content-Type: ${contentType}`,
`Content-Range: ${range.firstPos}-${range.lastPos}/${size}`,
"",
content.slice(range.firstPos, range.lastPos + 1),
].map(toBuffer);
}
}

export function join(
list: readonly Uint8Array[],
separator: Uint8Array,
): Uint8Array {
return list.reduce((acc, cur, i) => {
if (!i) return concat(acc, cur);

return concat(acc, separator, cur);
}, new Uint8Array());
}

export function toBuffer(input: string | ArrayBuffer): ArrayBuffer {
return isString(input) ? new TextEncoder().encode(input) : input;
}

0 comments on commit 1741e1d

Please sign in to comment.