@sanity/client
Version:
Client for retrieving, creating and patching data from Sanity.io
152 lines (135 loc) • 4.83 kB
text/typescript
import {Observable} from 'rxjs'
import type {UploadEvent} from '../types'
import {ClientError, httpResponseFromFetch, ServerError} from './errors'
import {parseJsonText} from './request'
/**
* Options for a browser-side asset upload that needs progress events.
*
* @internal
*/
export interface BrowserUploadOptions {
url: string
method: string
headers: Record<string, string>
body: unknown
withCredentials: boolean
/** Milliseconds before the upload is aborted; `false` and `0` both disable the timeout. */
timeout?: number | false
signal?: AbortSignal
}
/**
* Run an asset upload through `XMLHttpRequest` so we can surface per-chunk
* upload progress events. get-it v9 / fetch has no equivalent hook in the
* browser, so the observable asset-upload API falls back to this path when
* `XMLHttpRequest` is available.
*
* @internal
*/
export function uploadWithProgress<T>(options: BrowserUploadOptions): Observable<UploadEvent<T>> {
return new Observable<UploadEvent<T>>((subscriber) => {
const xhr = new XMLHttpRequest()
const {url, method, headers, body, withCredentials, timeout, signal} = options
xhr.open(method, url)
xhr.withCredentials = withCredentials
if (typeof timeout === 'number' && timeout > 0) {
xhr.timeout = timeout
}
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value)
}
xhr.upload.onprogress = (e) => {
subscriber.next({
type: 'progress',
stage: 'upload',
percent: e.lengthComputable ? Math.round((e.loaded / e.total) * 100) : 0,
total: e.total || undefined,
loaded: e.loaded,
lengthComputable: e.lengthComputable,
})
}
xhr.onload = () => {
if (xhr.status >= 400) {
// Same typed errors as the fetch transport, so consumers can keep
// detecting `ClientError`/`ServerError` and reading `statusCode`,
// `responseBody` and the structured API `details` on failed uploads.
const errorHeaders = parseXhrResponseHeaders(xhr.getAllResponseHeaders())
const canonical = httpResponseFromFetch(
{
status: xhr.status,
statusText: xhr.statusText,
headers: errorHeaders,
body: parseJsonText(xhr.responseText, errorHeaders),
url: xhr.responseURL,
},
url,
method,
)
subscriber.error(
xhr.status >= 500 ? new ServerError(canonical) : new ClientError(canonical),
)
return
}
let responseBody: T
try {
responseBody = JSON.parse(xhr.responseText) as T
} catch {
subscriber.error(new Error('Failed to parse upload response as JSON'))
return
}
subscriber.next({type: 'response', body: responseBody})
subscriber.complete()
}
xhr.onerror = () => {
subscriber.error(new Error('XHR upload network error'))
}
xhr.ontimeout = () => {
// Same error shape as the fetch transport's timeout rejection.
subscriber.error(
new DOMException(
`The operation timed out after ${timeout}ms while attempting to reach ${url}`,
'TimeoutError',
),
)
}
xhr.onabort = () => {
subscriber.error(new DOMException('Upload aborted', 'AbortError'))
}
const onSignalAbort = () => xhr.abort()
if (signal) {
if (signal.aborted) {
// `xhr.abort()` before `send()` fires no `abort` event per spec, so
// error out directly instead of relying on `onabort`.
subscriber.error(new DOMException('Upload aborted', 'AbortError'))
return undefined
}
signal.addEventListener('abort', onSignalAbort, {once: true})
}
xhr.send(body as XMLHttpRequestBodyInit)
// Unsubscribing cancels the in-flight upload, mirroring how the fetch
// path aborts its request (`_observe`). After settle this is a no-op —
// except for detaching from the caller's signal, which may be long-lived
// and must not accumulate a listener per upload.
return () => {
signal?.removeEventListener('abort', onSignalAbort)
xhr.abort()
}
})
}
/**
* Parse `XMLHttpRequest.getAllResponseHeaders()` output (CRLF-separated
* `name: value` lines) into a `Headers` instance.
*/
function parseXhrResponseHeaders(raw: string): Headers {
const headers = new Headers()
for (const line of raw.split('\r\n')) {
const separator = line.indexOf(':')
if (separator <= 0) continue
try {
headers.append(line.slice(0, separator).trim(), line.slice(separator + 1).trim())
} catch {
// Skip header lines the Headers constructor rejects — better a partial
// header record on the error than no error details at all.
}
}
return headers
}