one
Version:
One is a React framework for web, iOS, and Android apps.
346 lines (317 loc) • 12 kB
text/typescript
// @ts-ignore react native ships no types for its blob manager
import BlobManagerModule from 'react-native/Libraries/Blob/BlobManager'
import { NativeModules, Platform } from 'react-native'
import { NitroModules } from 'react-native-nitro-modules'
import type {
FetchBlobRef,
FetchFormPart,
FetchHeader,
FetchNativeRequest,
FetchNativeResponse,
OneFetch,
} from '../specs/OneFetch.nitro'
// react native's fetch runs over XMLHttpRequest and buffers the whole
// response before resolving, so response.body does not exist and streamed
// responses (ndjson, server-sent events, model output) arrive all at once.
// this fetch runs each request on URLSession / OkHttp and hands every chunk to
// response.body as it lands, and replaces the global so libraries that stream
// (ai sdks) work unchanged. it keeps what react native's fetch accepts:
// string, URLSearchParams, ArrayBuffer, typed array, Blob and FormData bodies
// (including { uri, name, type } file parts), file: and content: urls, and
// Request inputs.
let hybrid: OneFetch | undefined
// wraps a blob id from the native store in a Blob that releases it on gc
const BlobManager: {
createFromOptions(options: {
blobId: string
offset: number
size: number
type: string
lastModified: number
}): Blob
} = BlobManagerModule
function native(): OneFetch {
hybrid ??= NitroModules.createHybridObject<OneFetch>('OneFetch')
return hybrid
}
let lastId = 0
type NativeBody = Pick<FetchNativeRequest, 'body' | 'blob' | 'form' | 'boundary'>
function bufferOf(view: ArrayBufferView): ArrayBuffer {
const { buffer, byteOffset, byteLength } = view
if (!(buffer instanceof ArrayBuffer)) {
throw new TypeError('fetch: a body backed by a SharedArrayBuffer is not supported')
}
return buffer.slice(byteOffset, byteOffset + byteLength)
}
function blobRef(blob: Blob): FetchBlobRef {
const data: unknown = Reflect.get(blob, 'data')
if (
typeof data !== 'object' ||
data === null ||
!('blobId' in data) ||
typeof data.blobId !== 'string' ||
!('offset' in data) ||
typeof data.offset !== 'number'
) {
throw new TypeError('fetch: the Blob body is not a React Native blob')
}
return { blobId: data.blobId, offset: data.offset, size: blob.size }
}
function formParts(form: FormData): FetchFormPart[] {
const entries: unknown = Reflect.get(form, '_parts')
if (!Array.isArray(entries)) {
throw new TypeError('fetch: the FormData body is not a React Native FormData')
}
return entries.map((entry): FetchFormPart => {
const [name, value]: unknown[] = entry
if (typeof name !== 'string') throw new TypeError('fetch: a FormData name must be a string')
if (value instanceof Blob) {
const filename: unknown = Reflect.get(value, 'name')
return {
name,
blob: blobRef(value),
filename: typeof filename === 'string' ? filename : 'blob',
type: value.type || 'application/octet-stream',
}
}
if (typeof value === 'object' && value !== null && 'uri' in value) {
if (typeof value.uri !== 'string') throw new TypeError('fetch: a FormData uri must be a string')
const filename = 'name' in value && typeof value.name === 'string' ? value.name : undefined
const type = 'type' in value && typeof value.type === 'string' ? value.type : undefined
return { name, uri: value.uri, filename, type }
}
return { name, value: String(value) }
})
}
// the body as native sends it, plus the content-type it implies
function encode(body: unknown): { native: NativeBody; type?: string; forceType?: boolean } {
if (typeof body === 'string') {
return {
native: { body: bufferOf(new TextEncoder().encode(body)) },
type: 'text/plain;charset=UTF-8',
}
}
if (body instanceof URLSearchParams) {
return {
native: { body: bufferOf(new TextEncoder().encode(body.toString())) },
type: 'application/x-www-form-urlencoded;charset=UTF-8',
}
}
if (body instanceof ArrayBuffer) return { native: { body: body.slice(0) } }
if (ArrayBuffer.isView(body)) return { native: { body: bufferOf(body) } }
if (body instanceof Blob) {
return { native: { blob: blobRef(body) }, type: body.type || undefined }
}
if (body instanceof FormData) {
const boundary = `----OneFormBoundary${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
// react native always writes its own multipart content-type, so apps
// that set a bare multipart/form-data header still send the boundary
return {
native: { form: formParts(body), boundary },
type: `multipart/form-data; boundary=${boundary}`,
forceType: true,
}
}
throw new TypeError(
'fetch: body must be a string, URLSearchParams, ArrayBuffer, typed array, Blob or FormData'
)
}
// 204, 205 and 304 carry no body by definition, and neither does HEAD
function hasBody(method: string, status: number) {
return method !== 'HEAD' && status !== 204 && status !== 205 && status !== 304
}
function concat(chunks: Uint8Array[]): Uint8Array {
let length = 0
for (const chunk of chunks) length += chunk.byteLength
const bytes = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return bytes
}
type Head = Omit<FetchNativeResponse, 'headers'> & { headers: Headers }
class StreamingResponse {
readonly type = 'default'
readonly ok: boolean
readonly status: number
readonly statusText: string
readonly url: string
readonly redirected: boolean
readonly headers: Headers
readonly body: ReadableStream<Uint8Array> | null
constructor(head: Head, body: ReadableStream<Uint8Array> | null) {
this.status = head.status
this.ok = head.status >= 200 && head.status < 300
this.statusText = head.statusText
this.url = head.url
this.redirected = head.redirected
this.headers = head.headers
this.body = body
}
get bodyUsed(): boolean {
return this.body?.locked ?? false
}
async bytes(): Promise<Uint8Array> {
if (!this.body) return new Uint8Array(0)
if (this.body.locked) throw new TypeError('Already read')
const reader = this.body.getReader()
const chunks: Uint8Array[] = []
for (;;) {
const { done, value } = await reader.read()
if (done) return concat(chunks)
chunks.push(value)
}
}
async arrayBuffer(): Promise<ArrayBuffer> {
return bufferOf(await this.bytes())
}
async text(): Promise<string> {
return new TextDecoder().decode(await this.bytes())
}
async json(): Promise<any> {
return JSON.parse(await this.text())
}
// react native's Blob cannot be built from bytes in js, so the bytes go
// into its native blob store, as xhr responses do
async blob(): Promise<Blob> {
const bytes = await this.bytes()
const blobId = native().storeBlob(bufferOf(bytes))
return BlobManager.createFromOptions({
blobId,
offset: 0,
size: bytes.byteLength,
type: this.headers.get('content-type') ?? '',
lastModified: Date.now(),
})
}
// urlencoded bodies, as react native's fetch reads them
async formData(): Promise<FormData> {
const form = new FormData()
new URLSearchParams(await this.text()).forEach((value, name) => {
form.append(name, value)
})
return form
}
clone(): StreamingResponse {
if (this.bodyUsed) throw new TypeError('Already read')
const head = { ...this, headers: new Headers(this.headers) }
if (!this.body) return new StreamingResponse(head, null)
const [mine, theirs] = this.body.tee()
Reflect.set(this, 'body', mine)
return new StreamingResponse(head, theirs)
}
}
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? Object.assign(new Error('Aborted'), { name: 'AbortError' })
}
async function fetch(input: string | URL | Request, init: RequestInit = {}): Promise<StreamingResponse> {
const request = input instanceof Request ? input : undefined
const signal = init.signal ?? request?.signal
if (signal?.aborted) throw abortReason(signal)
const url = request ? request.url : String(input)
const method = (init.method ?? request?.method ?? 'GET').toUpperCase()
const headers = new Headers(init.headers ?? request?.headers)
const credentials = init.credentials ?? request?.credentials
// a Request's body is sent as it was given, which is what react native's
// fetch sends, so a FormData body keeps its multipart encoding
const body: unknown = init.body ?? (request ? Reflect.get(request, '_bodyInit') : undefined)
let nativeBody: NativeBody = {}
if (body != null) {
if (method === 'GET' || method === 'HEAD') {
throw new TypeError(`fetch: a ${method} request cannot have a body`)
}
const encoded = encode(body)
nativeBody = encoded.native
if (encoded.type && (encoded.forceType || !headers.has('content-type'))) {
headers.set('content-type', encoded.type)
}
}
const list: FetchHeader[] = []
headers.forEach((value, name) => {
list.push({ name, value })
})
return new Promise((resolve, reject) => {
const id = ++lastId
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
let response: StreamingResponse | undefined
let settled = false
const settle = () => {
settled = true
signal?.removeEventListener('abort', onAbort)
}
const fail = (error: unknown) => {
settle()
if (response) controller?.error(error)
else reject(error)
}
const onAbort = () => {
if (settled || !signal) return
native().cancel(id)
fail(abortReason(signal))
}
signal?.addEventListener('abort', onAbort)
native().start(
id,
{ url, method, headers: list, ...nativeBody, omitCredentials: credentials === 'omit' },
(head) => {
if (settled) return
const responseHeaders = new Headers()
for (const { name, value } of head.headers) responseHeaders.append(name, value)
const stream = hasBody(method, head.status)
? new ReadableStream<Uint8Array>({
start(streamController) {
controller = streamController
},
cancel() {
if (settled) return
settle()
native().cancel(id)
},
})
: null
response = new StreamingResponse({ ...head, headers: responseHeaders }, stream)
resolve(response)
},
(chunk) => {
if (!settled) controller?.enqueue(new Uint8Array(chunk))
},
() => {
if (settled) return
settle()
controller?.close()
},
(message) => {
if (!settled) fail(new TypeError(`Network request failed: ${message}`))
}
)
})
}
// replaces react native's buffered fetch. installs nothing when the binary
// has no OneFetch, leaving react native's fetch in place.
export function installFetch(): void {
if (!NitroModules.hasHybridObject('OneFetch')) return
// react native defines Headers, Request and Response lazily through
// whatwg-fetch, which installs them only while the global fetch is still
// its own. read them first so all three exist before fetch is replaced.
if (
typeof globalThis.Headers !== 'function' ||
typeof globalThis.Request !== 'function' ||
typeof globalThis.Response !== 'function'
) {
throw new Error(
"fetch: react native's Headers, Request and Response must be installed before One's fetch"
)
}
// libraries check `instanceof Response`; every member is still our own
Object.setPrototypeOf(StreamingResponse.prototype, globalThis.Response.prototype)
// ios reaches react native's blob store through a bridge module's registry
if (Platform.OS === 'ios') NativeModules.OneFetchBlobStore.install()
Object.defineProperty(globalThis, 'fetch', {
value: fetch,
writable: true,
configurable: true,
enumerable: true,
})
}