UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

346 lines (317 loc) • 12 kB
// @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, }) }