Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/blob-store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ export class BlobStore extends ReadyResource {
* @param {import('hyperdrive').HyperdriveEntry} entry Hyperdrive entry
* @param {object} [options]
* @param {boolean} [options.wait=false] Set to `true` to wait for a blob to download, otherwise will throw if blob is not available locally
* @param {number} [options.start] Byte offset to start reading from
* @param {number} [options.length] Number of bytes to read
* @returns {Promise<Readable>}
*/
async createReadStreamFromEntry(driveId, entry, options = { wait: false }) {
Expand Down
72 changes: 71 additions & 1 deletion src/fastify-plugins/blobs.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,22 @@ async function routes(fastify, options) {
}

const { metadata } = entry.value
const totalLength = entry.value.blob.byteLength

// Without Content-Length (and, for seeking, Range support), iOS's
// AVPlayer can't determine the resource's duration and treats it as
// an indefinite live stream, which stalls forever instead of playing.
const range = parseRangeHeader(request.headers.range, totalLength)

let blobStream
try {
blobStream = await blobStore.createReadStreamFromEntry(driveId, entry)
blobStream = await blobStore.createReadStreamFromEntry(
driveId,
entry,
range
? { wait: false, start: range.start, length: range.length }
: { wait: false }
)
} catch (e) {
reply.code(404)
throw ensureKnownError(e)
Expand All @@ -121,6 +133,18 @@ async function routes(fastify, options) {
}
}

reply.header('Accept-Ranges', 'bytes')
if (range) {
reply.code(206)
reply.header(
'Content-Range',
`bytes ${range.start}-${range.end}/${totalLength}`
)
reply.header('Content-Length', range.length)
} else {
reply.header('Content-Length', totalLength)
}

// Extract the 'mimeType' property of the metadata and use it for the response header if found
if (
metadata &&
Expand Down Expand Up @@ -150,6 +174,52 @@ async function routes(fastify, options) {
)
}

/**
* Parses a single-range `Range: bytes=start-end` header (the only form
* clients like AVPlayer send for progressive download/seek requests).
* Returns null if the header is absent, malformed, or unsatisfiable, in
* which case the caller should fall back to serving the full resource.
*
* @param {string | undefined} rangeHeader
* @param {number} totalLength
* @returns {{ start: number, end: number, length: number } | null}
*/
function parseRangeHeader(rangeHeader, totalLength) {
if (!rangeHeader || totalLength === 0) return null

const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader)
if (!match) return null

const [, startStr, endStr] = match
if (!startStr && !endStr) return null

let start
let end
if (startStr) {
start = Number(startStr)
end = endStr ? Number(endStr) : totalLength - 1
} else {
// Suffix range, e.g. `bytes=-500` means "the last 500 bytes"
const suffixLength = Number(endStr)
start = Math.max(0, totalLength - suffixLength)
end = totalLength - 1
}

if (
!Number.isFinite(start) ||
!Number.isFinite(end) ||
start < 0 ||
start > end ||
start >= totalLength
) {
return null
}

end = Math.min(end, totalLength - 1)

return { start, end, length: end - start + 1 }
}

/**
* @param {Omit<BlobId, 'variant'> & { variant: BlobId['variant'] }} maybeBlobId
* @returns {maybeBlobId is BlobId}
Expand Down
Loading