expo
Version:
147 lines (124 loc) • 4.65 kB
text/typescript
import { ExpoFetchModule } from './ExpoFetchModule';
import type { NativeResponse } from './NativeRequest';
const ConcreteNativeResponse = ExpoFetchModule.NativeResponse as typeof NativeResponse;
export type AbortSubscriptionCleanupFunction = () => void;
// FormData from react-native is not compatible with the web standard.
// We need to extend it with the react-native FormData.
type RNFormData = Awaited<ReturnType<globalThis.Response['formData']>>;
type UniversalFormData = globalThis.FormData & RNFormData;
/**
* A response implementation for the `fetch.Response` API.
*/
export class FetchResponse extends ConcreteNativeResponse implements Response {
private streamingState: 'none' | 'started' | 'completed' = 'none';
private bodyStream: ReadableStream<Uint8Array> | null = null;
constructor(private readonly abortCleanupFunction: AbortSubscriptionCleanupFunction) {
super();
this.addListener('readyForJSFinalization', this.finalize);
}
get body(): ReadableStream<Uint8Array> | null {
if (this.bodyStream == null) {
const response = this;
// This flag prevents enqueuing data after the stream is closed or canceled.
// Because it might be too late for the multithreaded native code to stop enqueuing data,
// we cannot simply rely on the native code to stop sending `didReceiveResponseData`.
let isControllerClosed = false;
this.bodyStream = new ReadableStream({
start(controller) {
if (response.streamingState === 'completed') {
return;
}
response.addListener('didReceiveResponseData', (data: Uint8Array) => {
if (!isControllerClosed) {
controller.enqueue(data);
}
});
response.addListener('didComplete', () => {
controller.close();
isControllerClosed = true;
});
response.addListener('didFailWithError', (error: string) => {
controller.error(new Error(error));
isControllerClosed = true;
});
},
async pull(controller) {
if (response.streamingState === 'none') {
const completedData = await response.startStreaming();
if (completedData != null) {
if (!isControllerClosed) {
controller.enqueue(completedData);
controller.close();
isControllerClosed = true;
}
response.streamingState = 'completed';
} else {
response.streamingState = 'started';
}
} else if (response.streamingState === 'completed') {
controller.close();
isControllerClosed = true;
}
},
cancel(reason) {
response.cancelStreaming(String(reason));
isControllerClosed = true;
},
});
}
return this.bodyStream;
}
get headers(): Headers {
return new Headers(this._rawHeaders);
}
get ok(): boolean {
return this.status >= 200 && this.status < 300;
}
public readonly type = 'default';
/**
* This method is not currently supported by react-native's Blob constructor.
*/
async blob(): Promise<Blob> {
const buffer = await this.arrayBuffer();
return new Blob([buffer]);
}
async formData(): Promise<UniversalFormData> {
// Reference implementation:
// https://chromium.googlesource.com/chromium/src/+/ed9f0b5933cf5ffb413be1ca844de5be140514bf/third_party/blink/renderer/core/fetch/body.cc#120
const text = await this.text();
const searchParams = new URLSearchParams(text);
const formData = new FormData() as UniversalFormData;
searchParams.forEach((value, key) => {
formData.append(key, value);
});
return formData;
}
async json(): Promise<any> {
const text = await this.text();
return JSON.parse(text);
}
async bytes(): Promise<Uint8Array> {
return new Uint8Array(await this.arrayBuffer());
}
toString(): string {
return `FetchResponse: { status: ${this.status}, statusText: ${this.statusText}, url: ${this.url} }`;
}
toJSON(): object {
return {
status: this.status,
statusText: this.statusText,
redirected: this.redirected,
url: this.url,
};
}
clone(): Response {
throw new Error('Not implemented');
}
private finalize = (): void => {
this.removeListener('readyForJSFinalization', this.finalize);
this.abortCleanupFunction();
this.removeAllListeners('didReceiveResponseData');
this.removeAllListeners('didComplete');
this.removeAllListeners('didFailWithError');
};
}