@apr144/generic-filehandle
Version:
uniform interface for accessing binary data from local files, remote HTTP resources, and browser Blob data
253 lines (234 loc) • 7.25 kB
text/typescript
import { Buffer } from 'buffer'
import {
GenericFilehandle,
FilehandleOptions,
Stats,
Fetcher,
PolyfilledResponse,
} from './filehandle'
import { encode } from 'base-64'
export default class RemoteFile implements GenericFilehandle {
protected url: string
protected auth: any = {}
private _stat?: Stats
private fetchImplementation: Fetcher
private baseOverrides: any = {}
private async getBufferFromResponse(
response: PolyfilledResponse,
): Promise<Buffer> {
if (typeof response.buffer === 'function') {
return response.buffer()
} else if (typeof response.arrayBuffer === 'function') {
const resp = await response.arrayBuffer()
return Buffer.from(resp)
} else {
throw new TypeError(
'invalid HTTP response object, has no buffer method, and no arrayBuffer method',
)
}
}
public constructor(source: string, opts: FilehandleOptions = {}) {
this.url = source
this.auth = opts.auth || {}
const fetch = opts.fetch || globalThis.fetch.bind(globalThis)
if (!fetch) {
throw new TypeError(
`no fetch function supplied, and none found in global environment`,
)
}
if (opts.overrides) {
this.baseOverrides = opts.overrides
}
this.fetchImplementation = fetch
}
public async fetch(
input: RequestInfo,
init: RequestInit | undefined,
): Promise<PolyfilledResponse> {
let response
try {
init = init || {}
if (this.auth && this.auth.user && this.auth.password) {
init.credentials = 'include'
init.headers = {}
init.headers.Authorization = `Basic ${encode(
this.auth.user + ':' + this.auth.password,
)}`
}
response = await this.fetchImplementation(input, {
...init,
// headers: headers,
})
} catch (e) {
if (`${e}`.includes('Failed to fetch')) {
// refetch to to help work around a chrome bug (discussed in
// generic-filehandle issue #72) in which the chrome cache returns a
// CORS error for content in its cache. see also
// https://github.com/GMOD/jbrowse-components/pull/1511
console.warn(
`generic-filehandle: refetching ${input} to attempt to work around chrome CORS header caching bug`,
)
init = init || {}
if (this.auth && this.auth.user && this.auth.password) {
init.credentials = 'include'
init.headers = {}
init.headers.Authorization = `Basic ${encode(
this.auth.user + ':' + this.auth.password,
)}`
}
response = await this.fetchImplementation(input, {
...init,
// headers: headers,
cache: 'reload',
})
} else {
throw e
}
}
return response
}
public async read(
buffer: Buffer,
offset = 0,
length: number,
position = 0,
opts: FilehandleOptions = {},
): Promise<{ bytesRead: number; buffer: Buffer }> {
const { headers = {}, signal, overrides = {} } = opts
if (length < Infinity) {
headers.range = `bytes=${position}-${position + length}`
} else if (length === Infinity && position !== 0) {
headers.range = `bytes=${position}-`
}
const args = {
...this.baseOverrides,
...overrides,
headers: {
...headers,
...overrides.headers,
...this.baseOverrides.headers,
},
method: 'GET',
redirect: 'follow',
mode: 'cors',
signal,
}
if (this.auth && this.auth.user && this.auth.password) {
headers.Authorization = `Basic ${encode(
this.auth.user + ':' + this.auth.password,
)}`
args.credentials = 'include'
}
const response = await this.fetch(this.url, args)
if (!response.ok) {
throw new Error(
`HTTP ${response.status} ${response.statusText} ${this.url}`,
)
}
if (
(response.status === 200 && position === 0) ||
response.status === 206
) {
const responseData = await this.getBufferFromResponse(response)
const bytesCopied = responseData.copy(
buffer,
offset,
0,
Math.min(length, responseData.length),
)
// try to parse out the size of the remote file
const res = response.headers.get('content-range')
const sizeMatch = /\/(\d+)$/.exec(res || '')
if (sizeMatch && sizeMatch[1]) {
this._stat = { size: parseInt(sizeMatch[1], 10) }
}
return { bytesRead: bytesCopied, buffer }
}
if (response.status === 200) {
throw new Error('${this.url} fetch returned status 200, expected 206')
}
if (response.status === 404) {
return { bytesRead: 0, buffer }
}
// TODO: try harder here to gather more information about what the problem is
throw new Error(`HTTP ${response.status} fetching ${this.url}`)
}
public async readFile(): Promise<Buffer>
public async readFile(options: BufferEncoding): Promise<string>
public async readFile<T extends undefined>(
options:
| Omit<FilehandleOptions, 'encoding'>
| (Omit<FilehandleOptions, 'encoding'> & { encoding: T }),
): Promise<Buffer>
public async readFile<T extends BufferEncoding>(
options: Omit<FilehandleOptions, 'encoding'> & { encoding: T },
): Promise<string>
readFile<T extends BufferEncoding>(
options: Omit<FilehandleOptions, 'encoding'> & { encoding: T },
): T extends BufferEncoding ? Promise<Buffer> : Promise<Buffer | string>
public async readFile(
options: FilehandleOptions | BufferEncoding = {},
): Promise<Buffer | string> {
let encoding
let opts
if (typeof options === 'string') {
encoding = options
opts = {}
} else {
encoding = options.encoding
opts = options
delete opts.encoding
}
const { headers = {}, signal, overrides = {} } = opts
const args = {
headers,
method: 'GET',
redirect: 'follow',
mode: 'cors',
signal,
...this.baseOverrides,
...overrides,
}
if (this.auth && this.auth.user && this.auth.password) {
headers.Authorization = `Basic ${encode(
this.auth.user + ':' + this.auth.password,
)}`
args.credentials = 'include'
}
const response = await this.fetch(this.url, args)
if (!response) {
throw new Error('generic-filehandle failed to fetch')
}
if (response.status === 404) {
return Buffer.alloc(1)
}
if (response.status !== 200) {
throw Object.assign(
new Error(`HTTP ${response.status} fetching ${this.url}`),
{
status: response.status,
},
)
}
if (encoding === 'utf8') {
return response.text()
}
if (encoding) {
throw new Error(`unsupported encoding: ${encoding}`)
}
return this.getBufferFromResponse(response)
}
public async stat(): Promise<Stats> {
if (!this._stat) {
const buf = Buffer.allocUnsafe(10)
await this.read(buf, 0, 10, 0)
if (!this._stat) {
throw new Error(`unable to determine size of file at ${this.url}`)
}
}
return this._stat
}
public async close(): Promise<void> {
return
}
}