@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
102 lines (97 loc) • 4.35 kB
text/typescript
import type { BlobRange } from './types'
/**
* Resolve a requested {@link BlobRange} against an object's real size, the way
* every byte-storing blob store has to before it slices.
*
* Clamps `length` to the end of the object and treats an absent `length` as
* "to the end", so the result is always the slice actually served — which is
* what `BlobObject.range` reports and what a `206` response's `Content-Range`
* is built from.
*
* Throws on an `offset` outside the object: that is a caller error, not a
* store error. A serve route knows the size (it is on the artifact record) and
* answers `416` from it, so a store only ever sees a satisfiable range unless
* something upstream is wrong — and silently returning an empty body there
* would serve a `206` that claims bytes it does not carry.
*
* @example
* ```ts
* const { offset, length } = resolveBlobRange(bytes.byteLength, range)
* const slice = bytes.subarray(offset, offset + length)
* ```
*/
export function resolveBlobRange(
size: number,
range: BlobRange,
): { offset: number; length: number } {
const { offset } = range
if (!Number.isInteger(offset) || offset < 0 || offset >= size) {
throw new RangeError(
`Blob range offset ${offset} is outside the object (size ${size}).`,
)
}
const remaining = size - offset
if (range.length === undefined) return { offset, length: remaining }
if (!Number.isInteger(range.length) || range.length < 0) {
throw new RangeError(`Blob range length ${range.length} is not valid.`)
}
return { offset, length: Math.min(range.length, remaining) }
}
/**
* Resolve an HTTP `Range` header against a known object size, for a route that
* serves artifact bytes.
*
* Returns the {@link BlobRange} to pass to `retrieveBlob` / `BlobStore.get`,
* `'unsatisfiable'` when the request names bytes the object does not have — answer
* `416`, whose `content-range` is the literal `bytes` `*` then a slash then the
* size — or `undefined` when there is no range to honour and the whole object
* should be served: an absent header, an invalid byte-range-spec (`bytes=100-50`,
* which RFC 9110 says to ignore rather than reject), and the forms this does not
* implement (multiple ranges, units other than `bytes`), which a server is
* always free to answer in full.
*
* @example
* ```ts
* const range = parseRangeHeader(request.headers.get('range'), record.size)
* if (range === 'unsatisfiable') return new Response(null, { status: 416 })
* const blob = await retrieveBlob(
* persistence,
* record,
* range ? { range } : undefined,
* )
* ```
*/
export function parseRangeHeader(
header: string | null | undefined,
size: number,
): BlobRange | 'unsatisfiable' | undefined {
const match = /^bytes=(\d*)-(\d*)$/.exec(header?.trim() ?? '')
if (!match) return undefined
const [, rawStart, rawEnd] = match
// `bytes=-` names nothing at all.
if (rawStart === '' && rawEnd === '') return undefined
// `bytes=-500` is the LAST 500 bytes, not "from 0 to 500" — the one form
// that is easy to read backwards, and reading it backwards serves the wrong
// bytes with a 206 that claims they are the right ones.
if (rawStart === '') {
const suffix = Number(rawEnd)
// A zero-length suffix names no bytes, and NO range is satisfiable against
// a zero-byte object — without the size check, `bytes=-1` on an empty
// artifact resolves to `{ offset: 0 }` and throws out of the store instead
// of answering 416.
if (suffix === 0 || size === 0) return 'unsatisfiable'
return { offset: Math.max(0, size - suffix) }
}
const start = Number(rawStart)
const end = rawEnd === '' ? undefined : Number(rawEnd)
// `bytes=100-50` is an INVALID byte-range-spec, not an unsatisfiable one
// (RFC 9110 §14.1.1). An invalid spec is ignored and the whole
// representation is served — answering 416 would fail a request that is
// supposed to succeed. Checked before satisfiability so the size cannot turn
// an ignorable spec into a 416.
if (end !== undefined && end < start) return undefined
if (start >= size) return 'unsatisfiable'
if (end === undefined) return { offset: start }
// `end` is inclusive, and past the end of the object it simply clamps.
return { offset: start, length: Math.min(end, size - 1) - start + 1 }
}