@eyepop.ai/react-native-eyepop 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +33 -0
- package/lib/commonjs/index.js +52 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/rn_http_client.js +113 -0
- package/lib/commonjs/rn_http_client.js.map +1 -0
- package/lib/commonjs/rn_http_header_parser.js +135 -0
- package/lib/commonjs/rn_http_header_parser.js.map +1 -0
- package/lib/commonjs/rn_http_response_parser.js +199 -0
- package/lib/commonjs/rn_http_response_parser.js.map +1 -0
- package/lib/commonjs/rn_local_file.js +38 -0
- package/lib/commonjs/rn_local_file.js.map +1 -0
- package/lib/commonjs/rn_streaming_fetch.js +111 -0
- package/lib/commonjs/rn_streaming_fetch.js.map +1 -0
- package/lib/module/index.js +48 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/rn_http_client.js +109 -0
- package/lib/module/rn_http_client.js.map +1 -0
- package/lib/module/rn_http_header_parser.js +131 -0
- package/lib/module/rn_http_header_parser.js.map +1 -0
- package/lib/module/rn_http_response_parser.js +194 -0
- package/lib/module/rn_http_response_parser.js.map +1 -0
- package/lib/module/rn_local_file.js +34 -0
- package/lib/module/rn_local_file.js.map +1 -0
- package/lib/module/rn_streaming_fetch.js +107 -0
- package/lib/module/rn_streaming_fetch.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +8 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/rn_http_client.d.ts +19 -0
- package/lib/typescript/commonjs/src/rn_http_client.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/rn_http_header_parser.d.ts +2 -0
- package/lib/typescript/commonjs/src/rn_http_header_parser.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/rn_http_response_parser.d.ts +21 -0
- package/lib/typescript/commonjs/src/rn_http_response_parser.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/rn_local_file.d.ts +5 -0
- package/lib/typescript/commonjs/src/rn_local_file.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/rn_streaming_fetch.d.ts +7 -0
- package/lib/typescript/commonjs/src/rn_streaming_fetch.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/index.d.ts +8 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/rn_http_client.d.ts +19 -0
- package/lib/typescript/module/src/rn_http_client.d.ts.map +1 -0
- package/lib/typescript/module/src/rn_http_header_parser.d.ts +2 -0
- package/lib/typescript/module/src/rn_http_header_parser.d.ts.map +1 -0
- package/lib/typescript/module/src/rn_http_response_parser.d.ts +21 -0
- package/lib/typescript/module/src/rn_http_response_parser.d.ts.map +1 -0
- package/lib/typescript/module/src/rn_local_file.d.ts +5 -0
- package/lib/typescript/module/src/rn_local_file.d.ts.map +1 -0
- package/lib/typescript/module/src/rn_streaming_fetch.d.ts +7 -0
- package/lib/typescript/module/src/rn_streaming_fetch.d.ts.map +1 -0
- package/package.json +202 -0
- package/src/index.ts +48 -0
- package/src/rn_http_client.ts +134 -0
- package/src/rn_http_header_parser.ts +138 -0
- package/src/rn_http_response_parser.ts +248 -0
- package/src/rn_local_file.ts +43 -0
- package/src/rn_streaming_fetch.ts +131 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import 'react-native-polyfill-globals/auto';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
import { reactNativeStreamingFetch } from './rn_streaming_fetch';
|
|
4
|
+
import { HttpClient } from '@eyepop.ai/eyepop';
|
|
5
|
+
import TcpSockets from 'react-native-tcp-socket';
|
|
6
|
+
import { ReadableStream } from 'web-streams-polyfill/ponyfill/es6';
|
|
7
|
+
|
|
8
|
+
/** HTTP client that will work around the RN fetch implementation and truly stream request and response bodies.
|
|
9
|
+
*
|
|
10
|
+
* While '{ textStreaming: true }' helps somewhat for the response body, it does not truly stream the response
|
|
11
|
+
* body, instead it keeps a large buffer and does not stream bytes to the consumer as they come in.
|
|
12
|
+
* Additionally, it does not support streaming request bodies which forces the consumer to load large files
|
|
13
|
+
* into memory w/o any possibility to avoid OOMs.
|
|
14
|
+
*
|
|
15
|
+
* This implementation implements selected HTTP interactions by using TCP sockets directly. The implementation
|
|
16
|
+
* is minimal and far from complete, e.g. no redirect support. For that reason, EyePop wil prefer to use the
|
|
17
|
+
* built-in fetch() except for cases when (a) the request requires a ReadableStream request body, or
|
|
18
|
+
* (b) the response is supposed to truly stream as indicated by the proprietary init parameter
|
|
19
|
+
* '{ eyepop: { responseStreaming: true} }'.
|
|
20
|
+
*
|
|
21
|
+
* */
|
|
22
|
+
export async function createHttpClientRN(logger: Logger): Promise<HttpClient> {
|
|
23
|
+
class ReactNativeHttpClient {
|
|
24
|
+
private readonly logger: Logger;
|
|
25
|
+
|
|
26
|
+
constructor(logger_: Logger) {
|
|
27
|
+
this.logger = logger_;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public async fetch(
|
|
31
|
+
input: RequestInfo | URL,
|
|
32
|
+
init?: RequestInit
|
|
33
|
+
): Promise<Response> {
|
|
34
|
+
init ||= {};
|
|
35
|
+
let streamBody: ReadableStream<Uint8Array> | undefined;
|
|
36
|
+
if (input instanceof Request && input.body instanceof ReadableStream) {
|
|
37
|
+
// Request body streaming not supported by RN fetch
|
|
38
|
+
streamBody = input.body;
|
|
39
|
+
} else if (init?.body instanceof ReadableStream) {
|
|
40
|
+
streamBody = init.body;
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
} else if (init?.eyepop?.responseStreaming) {
|
|
43
|
+
const requestBody = input instanceof Request ? input.body : init?.body;
|
|
44
|
+
if (requestBody !== undefined) {
|
|
45
|
+
// The limited HTTP post requires a ReadableStream request body
|
|
46
|
+
if (typeof requestBody === 'string') {
|
|
47
|
+
streamBody = new ReadableStream({
|
|
48
|
+
start: (controller) => {
|
|
49
|
+
controller.enqueue(Buffer.from(requestBody as string, 'utf8'));
|
|
50
|
+
controller.close();
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
} else if (requestBody instanceof Uint8Array) {
|
|
54
|
+
streamBody = new ReadableStream({
|
|
55
|
+
start: (controller) => {
|
|
56
|
+
controller.enqueue(requestBody as Uint8Array);
|
|
57
|
+
controller.close();
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
this.logger.warn(
|
|
62
|
+
`EyePop responseStreaming requires a request body of type ReadableStream | string | Uint8Array but is ${typeof requestBody}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (streamBody) {
|
|
68
|
+
let url: URL;
|
|
69
|
+
let method: string;
|
|
70
|
+
let headers: Headers;
|
|
71
|
+
if (input instanceof Request) {
|
|
72
|
+
url = new URL(input.url);
|
|
73
|
+
method = input.method;
|
|
74
|
+
headers = input.headers;
|
|
75
|
+
} else {
|
|
76
|
+
if (input instanceof URL) {
|
|
77
|
+
url = input;
|
|
78
|
+
} else {
|
|
79
|
+
url = new URL(input);
|
|
80
|
+
}
|
|
81
|
+
method = init?.method || 'POST';
|
|
82
|
+
headers = new Headers(init?.headers);
|
|
83
|
+
}
|
|
84
|
+
let contentLength;
|
|
85
|
+
if (headers.has('content-length')) {
|
|
86
|
+
contentLength = parseInt(headers.get('content-length') || '', 10);
|
|
87
|
+
} else {
|
|
88
|
+
headers.append('Transfer-Encoding', 'chunked');
|
|
89
|
+
}
|
|
90
|
+
this.logger.debug(
|
|
91
|
+
`reactNativeStreamingFetch() BEFORE ${method} ${url}`
|
|
92
|
+
);
|
|
93
|
+
const response = await reactNativeStreamingFetch(
|
|
94
|
+
url,
|
|
95
|
+
method,
|
|
96
|
+
headers,
|
|
97
|
+
streamBody,
|
|
98
|
+
contentLength,
|
|
99
|
+
TcpSockets,
|
|
100
|
+
this.logger
|
|
101
|
+
);
|
|
102
|
+
this.logger.debug(`AFTER ${method} ${url} -> ${response.status}`);
|
|
103
|
+
return response;
|
|
104
|
+
}
|
|
105
|
+
// @ts-ignore
|
|
106
|
+
init.reactNative = { textStreaming: true };
|
|
107
|
+
const logLine = logLineFromRequest(input, init);
|
|
108
|
+
this.logger.debug(`ReactNative fetch() BEFORE ${logLine}`);
|
|
109
|
+
const response = await fetch(input, init);
|
|
110
|
+
this.logger.debug(`AFTER ${logLine} -> ${response.status}`);
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public async close(): Promise<void> {}
|
|
115
|
+
|
|
116
|
+
public isFullDuplex() {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return new ReactNativeHttpClient(logger);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function logLineFromRequest(
|
|
125
|
+
input: RequestInfo | URL,
|
|
126
|
+
init?: RequestInit
|
|
127
|
+
): string {
|
|
128
|
+
const url = input instanceof Request ? input.url : input;
|
|
129
|
+
const method =
|
|
130
|
+
input instanceof Request
|
|
131
|
+
? input.method
|
|
132
|
+
: init?.method || (init?.body !== undefined ? 'POST' : 'GET');
|
|
133
|
+
return `${method} ${url}`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// RFC-2068 Start-Line definitions:
|
|
2
|
+
// Request-Line: Method SP Request-URI SP HTTP-Version CRLF
|
|
3
|
+
// Status-Line: HTTP-Version SP Status-Code SP Reason-Phrase CRLF
|
|
4
|
+
var startLine = /^[A-Z_]+(\/\d\.\d)? /;
|
|
5
|
+
var requestLine = /^([A-Z_]+) (.+) [A-Z]+\/(\d)\.(\d)$/;
|
|
6
|
+
var statusLine = /^[A-Z]+\/(\d)\.(\d) (\d{3}) (.*)$/;
|
|
7
|
+
|
|
8
|
+
export function parseHttpHeader(lines: string[], onlyHeaders: boolean) {
|
|
9
|
+
let line = lines[0] || '';
|
|
10
|
+
let match;
|
|
11
|
+
|
|
12
|
+
if (onlyHeaders && startLine.test(line)) {
|
|
13
|
+
return parseHeaders(lines);
|
|
14
|
+
} else if ((match = line.match(requestLine)) !== null) {
|
|
15
|
+
return {
|
|
16
|
+
method: match[1],
|
|
17
|
+
url: match[2],
|
|
18
|
+
version: {
|
|
19
|
+
major: parseInt(match[3] || '', 10),
|
|
20
|
+
minor: parseInt(match[4] || '', 10),
|
|
21
|
+
},
|
|
22
|
+
headers: parseHeaders(lines),
|
|
23
|
+
};
|
|
24
|
+
} else if ((match = line.match(statusLine)) !== null) {
|
|
25
|
+
return {
|
|
26
|
+
version: {
|
|
27
|
+
major: parseInt(match[1] || '', 10),
|
|
28
|
+
minor: parseInt(match[2] || '', 10),
|
|
29
|
+
},
|
|
30
|
+
statusCode: parseInt(match[3] || '', 10),
|
|
31
|
+
statusMessage: match[4],
|
|
32
|
+
headers: parseHeaders(lines),
|
|
33
|
+
};
|
|
34
|
+
} else {
|
|
35
|
+
return parseHeaders(lines);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseHeaders(lines: string[]) {
|
|
40
|
+
var headers = {};
|
|
41
|
+
var i = 0;
|
|
42
|
+
var line = lines[i] || '';
|
|
43
|
+
var index, name, value;
|
|
44
|
+
|
|
45
|
+
if (startLine.test(line)) {
|
|
46
|
+
line = line[++i] || '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
while (line) {
|
|
50
|
+
// subsequent lines in multi-line headers start with whitespace
|
|
51
|
+
if (line[0] === ' ' || line[0] === '\t') {
|
|
52
|
+
value += ' ' + line.trim();
|
|
53
|
+
i++;
|
|
54
|
+
if (i < lines.length) {
|
|
55
|
+
line = lines[i] || '';
|
|
56
|
+
} else {
|
|
57
|
+
line = '';
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (name && value) {
|
|
63
|
+
addHeaderLine(name, value, headers);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
index = line.indexOf(':');
|
|
67
|
+
name = line.substr(0, index);
|
|
68
|
+
value = line.substr(index + 1).trim();
|
|
69
|
+
i++;
|
|
70
|
+
if (i < lines.length) {
|
|
71
|
+
line = lines[i] || '';
|
|
72
|
+
} else {
|
|
73
|
+
line = '';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (name && value) {
|
|
78
|
+
addHeaderLine(name, value, headers);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The following function is lifted from:
|
|
85
|
+
// https://github.com/nodejs/node/blob/f1294f5bfd7f02bce8029818be9c92de59749137/lib/_http_incoming.js#L116-L170
|
|
86
|
+
//
|
|
87
|
+
// Add the given (field, value) pair to the message
|
|
88
|
+
//
|
|
89
|
+
// Per RFC2616, section 4.2 it is acceptable to join multiple instances of the
|
|
90
|
+
// same header with a ', ' if the header in question supports specification of
|
|
91
|
+
// multiple values this way. If not, we declare the first instance the winner
|
|
92
|
+
// and drop the second. Extended header fields (those beginning with 'x-') are
|
|
93
|
+
// always joined.
|
|
94
|
+
function addHeaderLine(field: string, value: string, dest: any) {
|
|
95
|
+
field = field.toLowerCase();
|
|
96
|
+
switch (field) {
|
|
97
|
+
// Array headers:
|
|
98
|
+
case 'set-cookie':
|
|
99
|
+
if (dest[field] !== undefined) {
|
|
100
|
+
dest[field].push(value);
|
|
101
|
+
} else {
|
|
102
|
+
dest[field] = [value];
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
// list is taken from:
|
|
107
|
+
// https://mxr.mozilla.org/mozilla/source/netwerk/protocol/http/src/nsHttpHeaderArray.cpp
|
|
108
|
+
case 'content-type':
|
|
109
|
+
case 'content-length':
|
|
110
|
+
case 'user-agent':
|
|
111
|
+
case 'referer':
|
|
112
|
+
case 'host':
|
|
113
|
+
case 'authorization':
|
|
114
|
+
case 'proxy-authorization':
|
|
115
|
+
case 'if-modified-since':
|
|
116
|
+
case 'if-unmodified-since':
|
|
117
|
+
case 'from':
|
|
118
|
+
case 'location':
|
|
119
|
+
case 'max-forwards':
|
|
120
|
+
case 'retry-after':
|
|
121
|
+
case 'etag':
|
|
122
|
+
case 'last-modified':
|
|
123
|
+
case 'server':
|
|
124
|
+
case 'age':
|
|
125
|
+
case 'expires':
|
|
126
|
+
// drop duplicates
|
|
127
|
+
if (dest[field] === undefined) dest[field] = value;
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
default:
|
|
131
|
+
// make comma-separated list
|
|
132
|
+
if (typeof dest[field] === 'string') {
|
|
133
|
+
dest[field] += ', ' + value;
|
|
134
|
+
} else {
|
|
135
|
+
dest[field] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import 'react-native-polyfill-globals/auto';
|
|
2
|
+
|
|
3
|
+
import { EyepopLineDecoder } from '@eyepop.ai/eyepop';
|
|
4
|
+
import { parseHttpHeader } from './rn_http_header_parser';
|
|
5
|
+
import { Logger } from 'pino';
|
|
6
|
+
import {
|
|
7
|
+
ReadableStream,
|
|
8
|
+
ReadableStreamDefaultController,
|
|
9
|
+
} from 'web-streams-polyfill/ponyfill/es6';
|
|
10
|
+
|
|
11
|
+
enum ResponseState {
|
|
12
|
+
RECEIVING_HEADER,
|
|
13
|
+
RECEIVING_BODY,
|
|
14
|
+
RECEIVING_BODY_CHUNKED,
|
|
15
|
+
RECEIVING_COMPLETED,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ResponseParser {
|
|
19
|
+
private error: any;
|
|
20
|
+
private readonly logger: Logger;
|
|
21
|
+
private readonly resolve: (value: Response | PromiseLike<Response>) => void;
|
|
22
|
+
private readonly reject: (reason?: any) => void;
|
|
23
|
+
private responseChunkRemainder = 0;
|
|
24
|
+
private responseReadBuffer: Buffer | undefined;
|
|
25
|
+
private responseState: ResponseState;
|
|
26
|
+
|
|
27
|
+
private pullController: any;
|
|
28
|
+
private pullResolve: (() => void) | undefined;
|
|
29
|
+
private pullReject: ((reason?: any) => void) | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
logger: Logger,
|
|
33
|
+
resolve: (value: Response | PromiseLike<Response>) => void,
|
|
34
|
+
reject: (reason?: any) => void
|
|
35
|
+
) {
|
|
36
|
+
this.logger = logger;
|
|
37
|
+
this.resolve = resolve;
|
|
38
|
+
this.reject = reject;
|
|
39
|
+
this.error = null;
|
|
40
|
+
this.responseReadBuffer = undefined;
|
|
41
|
+
this.responseState = ResponseState.RECEIVING_HEADER;
|
|
42
|
+
this.pullController = undefined;
|
|
43
|
+
this.pullResolve = undefined;
|
|
44
|
+
this.pullReject = undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public processResponseReadBuffer(data: Buffer): boolean {
|
|
48
|
+
if (this.error) {
|
|
49
|
+
this.logger.info(
|
|
50
|
+
`RN http streamed response, received ${data.length} bytes in error mode`
|
|
51
|
+
);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (this.responseReadBuffer) {
|
|
55
|
+
this.responseReadBuffer = Buffer.concat([this.responseReadBuffer, data]);
|
|
56
|
+
} else {
|
|
57
|
+
this.responseReadBuffer = Buffer.from(data.subarray());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (this.responseState === ResponseState.RECEIVING_COMPLETED) {
|
|
61
|
+
if (this.pullController) {
|
|
62
|
+
this.pullController.close();
|
|
63
|
+
if (this.pullResolve) {
|
|
64
|
+
this.pullResolve();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
} else if (this.responseState === ResponseState.RECEIVING_HEADER) {
|
|
69
|
+
const headerEnd = this.responseReadBuffer.indexOf('\r\n\r\n');
|
|
70
|
+
if (headerEnd >= 0) {
|
|
71
|
+
let headerChunk = this.responseReadBuffer.subarray(0, headerEnd + 4);
|
|
72
|
+
this.responseReadBuffer = Buffer.from(
|
|
73
|
+
this.responseReadBuffer.subarray(headerEnd + 4)
|
|
74
|
+
);
|
|
75
|
+
const maxHeaderLines = 128;
|
|
76
|
+
let responseHeaderLines: string[] = [];
|
|
77
|
+
const lineDecoder = new EyepopLineDecoder();
|
|
78
|
+
for (const line of lineDecoder.decode(headerChunk)) {
|
|
79
|
+
if (line.length > 0) {
|
|
80
|
+
responseHeaderLines.push(line);
|
|
81
|
+
if (responseHeaderLines.length > maxHeaderLines) {
|
|
82
|
+
this.error = `Response header exceeds ${maxHeaderLines} lines`;
|
|
83
|
+
this.reject(this.error);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let responseHeader: any = parseHttpHeader(responseHeaderLines, false);
|
|
89
|
+
if (responseHeader.statusCode === undefined) {
|
|
90
|
+
this.logger.warn(
|
|
91
|
+
`Could not find status header in HTTP response ${JSON.stringify(responseHeader)}`
|
|
92
|
+
);
|
|
93
|
+
this.reject('missing status header in HTTP response');
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const headers = new Headers(responseHeader.headers);
|
|
98
|
+
if (headers.has('transfer-encoding')) {
|
|
99
|
+
if (headers.get('transfer-encoding')?.toLowerCase() === 'chunked') {
|
|
100
|
+
this.responseState = ResponseState.RECEIVING_BODY_CHUNKED;
|
|
101
|
+
this.responseChunkRemainder = 0;
|
|
102
|
+
} else {
|
|
103
|
+
this.error = `Response with unsupported transfer-encoding '${headers.get('transfer-encoding')}'`;
|
|
104
|
+
this.reject(this.error);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
} else if (headers.has('content-length')) {
|
|
108
|
+
this.responseState = ResponseState.RECEIVING_BODY;
|
|
109
|
+
this.responseChunkRemainder = parseInt(
|
|
110
|
+
headers.get('content-length') || '-1',
|
|
111
|
+
10
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
this.responseState = ResponseState.RECEIVING_BODY_CHUNKED;
|
|
115
|
+
this.responseChunkRemainder = 0;
|
|
116
|
+
}
|
|
117
|
+
const responseBody = new ReadableStream<Uint8Array>({
|
|
118
|
+
pull: (controller) => {
|
|
119
|
+
if (
|
|
120
|
+
this.error ||
|
|
121
|
+
this.responseState === ResponseState.RECEIVING_COMPLETED
|
|
122
|
+
) {
|
|
123
|
+
// done with the whole request
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (this.pull(controller)) {
|
|
127
|
+
// one chunk enqueued, fulfilled the pull() protocol
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// nothing to enqueue yet, need to defer top a promise
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
if (this.pullController) {
|
|
133
|
+
if (this.pullReject) {
|
|
134
|
+
this.pullReject('pull, we got a pending controller already');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
this.pullController = controller;
|
|
138
|
+
this.pullResolve = resolve;
|
|
139
|
+
this.pullReject = reject;
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
cancel: (reason) => {
|
|
143
|
+
if (!this.error) {
|
|
144
|
+
this.error = `cancelled: ${reason}`;
|
|
145
|
+
this.reject(this.error);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
this.resolve(
|
|
150
|
+
// @ts-ignore
|
|
151
|
+
new Response(responseBody, {
|
|
152
|
+
status: responseHeader.statusCode,
|
|
153
|
+
statusText: responseHeader.statusText,
|
|
154
|
+
headers: headers,
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} else if (this.pullController) {
|
|
159
|
+
if (this.pull(this.pullController)) {
|
|
160
|
+
this.pullController = undefined;
|
|
161
|
+
if (this.pullResolve) {
|
|
162
|
+
this.pullResolve();
|
|
163
|
+
}
|
|
164
|
+
this.pullResolve = undefined;
|
|
165
|
+
this.pullReject = undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private pull(
|
|
172
|
+
controller: ReadableStreamDefaultController<Uint8Array>
|
|
173
|
+
): boolean {
|
|
174
|
+
if (this.responseState === ResponseState.RECEIVING_COMPLETED) {
|
|
175
|
+
controller.close();
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (this.responseReadBuffer === undefined) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (this.responseState === ResponseState.RECEIVING_BODY) {
|
|
182
|
+
controller.enqueue(this.responseReadBuffer);
|
|
183
|
+
this.responseChunkRemainder -= this.responseReadBuffer.length;
|
|
184
|
+
this.responseReadBuffer = undefined;
|
|
185
|
+
if (this.responseChunkRemainder <= 0) {
|
|
186
|
+
this.responseState = ResponseState.RECEIVING_COMPLETED;
|
|
187
|
+
controller.close();
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
} else if (this.responseState === ResponseState.RECEIVING_BODY_CHUNKED) {
|
|
191
|
+
while (this.responseReadBuffer.length > 1) {
|
|
192
|
+
if (this.responseChunkRemainder === 0) {
|
|
193
|
+
// need to find the new chunk length
|
|
194
|
+
let chunkHeaderEnd = 0;
|
|
195
|
+
while (this.responseReadBuffer.length >= 2 && chunkHeaderEnd === 0) {
|
|
196
|
+
chunkHeaderEnd = this.responseReadBuffer.indexOf('\r\n');
|
|
197
|
+
if (chunkHeaderEnd === 0) {
|
|
198
|
+
// skip closing \r\n line of a previous chunk
|
|
199
|
+
this.responseReadBuffer = Buffer.from(
|
|
200
|
+
this.responseReadBuffer.subarray(2)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (chunkHeaderEnd > 0) {
|
|
205
|
+
this.responseChunkRemainder = parseInt(
|
|
206
|
+
Buffer.from(this.responseReadBuffer, 0, chunkHeaderEnd).toString(
|
|
207
|
+
'utf8'
|
|
208
|
+
),
|
|
209
|
+
16
|
|
210
|
+
);
|
|
211
|
+
this.responseReadBuffer = Buffer.from(
|
|
212
|
+
this.responseReadBuffer.subarray(chunkHeaderEnd + 2)
|
|
213
|
+
);
|
|
214
|
+
if (this.responseChunkRemainder <= 0) {
|
|
215
|
+
this.responseState = ResponseState.RECEIVING_COMPLETED;
|
|
216
|
+
controller.close();
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
} else if (this.responseReadBuffer.length > 64) {
|
|
220
|
+
this.error =
|
|
221
|
+
'Cannot find chunked encoding header within 64 bytes, given up';
|
|
222
|
+
controller.error(this.error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
this.responseChunkRemainder > 0 &&
|
|
227
|
+
this.responseReadBuffer.length > 0
|
|
228
|
+
) {
|
|
229
|
+
const chunk = Buffer.from(
|
|
230
|
+
this.responseReadBuffer.subarray(0, this.responseChunkRemainder)
|
|
231
|
+
);
|
|
232
|
+
this.responseReadBuffer = Buffer.from(
|
|
233
|
+
this.responseReadBuffer.subarray(this.responseChunkRemainder)
|
|
234
|
+
);
|
|
235
|
+
controller.enqueue(chunk);
|
|
236
|
+
this.responseChunkRemainder -= chunk.length;
|
|
237
|
+
return true;
|
|
238
|
+
} else {
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
this.error = `Received more data in wrong state ${this.responseState}`;
|
|
244
|
+
controller.error(this.error);
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import 'react-native-polyfill-globals/auto';
|
|
2
|
+
import { PathSource, StreamSource } from '@eyepop.ai/eyepop';
|
|
3
|
+
import { Logger } from 'pino';
|
|
4
|
+
import { ReadableStream } from 'web-streams-polyfill/ponyfill/es6';
|
|
5
|
+
|
|
6
|
+
import { FileSystem } from 'react-native-file-access';
|
|
7
|
+
|
|
8
|
+
export async function resolvePathRN(
|
|
9
|
+
source: PathSource,
|
|
10
|
+
_: Logger
|
|
11
|
+
): Promise<StreamSource> {
|
|
12
|
+
const bufferSize = 1024 * 1024;
|
|
13
|
+
let pos = 0;
|
|
14
|
+
const size = (await FileSystem.stat(source.path)).size;
|
|
15
|
+
let eof = false;
|
|
16
|
+
const stream = new ReadableStream({
|
|
17
|
+
async pull(controller: any) {
|
|
18
|
+
if (eof) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const chunkSize = Math.min(bufferSize, size - pos);
|
|
22
|
+
const chunkAsB64 = await FileSystem.readFileChunk(
|
|
23
|
+
source.path,
|
|
24
|
+
pos,
|
|
25
|
+
chunkSize,
|
|
26
|
+
'base64'
|
|
27
|
+
);
|
|
28
|
+
if (chunkAsB64.length > 0) {
|
|
29
|
+
const chunk = Buffer.from(chunkAsB64, 'base64').subarray(0, chunkSize);
|
|
30
|
+
controller.enqueue(chunk);
|
|
31
|
+
pos += chunk.length;
|
|
32
|
+
} else {
|
|
33
|
+
eof = true;
|
|
34
|
+
controller.close();
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
stream: stream,
|
|
40
|
+
size: size,
|
|
41
|
+
mimeType: source.mimeType || 'image/*',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import 'react-native-polyfill-globals/auto';
|
|
2
|
+
|
|
3
|
+
import { Logger } from 'pino';
|
|
4
|
+
import { ResponseParser } from './rn_http_response_parser';
|
|
5
|
+
import { ReadableStream } from 'web-streams-polyfill/ponyfill/es6';
|
|
6
|
+
|
|
7
|
+
export async function reactNativeStreamingFetch(
|
|
8
|
+
url: URL,
|
|
9
|
+
method: string,
|
|
10
|
+
headers: Headers,
|
|
11
|
+
body: ReadableStream<Uint8Array> | undefined,
|
|
12
|
+
contentLength: number | undefined,
|
|
13
|
+
sockets: any,
|
|
14
|
+
logger: Logger
|
|
15
|
+
): Promise<Response> {
|
|
16
|
+
const isHttps = url.protocol === 'https:';
|
|
17
|
+
const port = url.port ? parseInt(url.port, 10) : isHttps ? 443 : 80;
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const connectOptions = {
|
|
20
|
+
port: port,
|
|
21
|
+
host: url.hostname,
|
|
22
|
+
reuseAddress: true,
|
|
23
|
+
};
|
|
24
|
+
const clientSocket = new sockets.Socket();
|
|
25
|
+
const client = isHttps ? new sockets.TLSSocket(clientSocket) : clientSocket;
|
|
26
|
+
|
|
27
|
+
let error: any;
|
|
28
|
+
const responseParser = new ResponseParser(logger, resolve, (reason) => {
|
|
29
|
+
error = reason;
|
|
30
|
+
reject(error);
|
|
31
|
+
client.destroy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
client.on('data', function (data: Buffer) {
|
|
35
|
+
responseParser.processResponseReadBuffer(data);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
client.on('error', function (err: any) {
|
|
39
|
+
logger.info(`error: ${err}`);
|
|
40
|
+
if (!error) {
|
|
41
|
+
error = err;
|
|
42
|
+
reject(error);
|
|
43
|
+
}
|
|
44
|
+
client.destroy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
client.on('close', function () {
|
|
48
|
+
if (!error) {
|
|
49
|
+
error = 'socket closed';
|
|
50
|
+
reject(error);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
client.connect(connectOptions, async () => {
|
|
55
|
+
const headerLines = [
|
|
56
|
+
`${method} ${url.pathname}${url.search} HTTP/1.1\r\n`,
|
|
57
|
+
`Host: ${url.host}\r\n`,
|
|
58
|
+
];
|
|
59
|
+
headers.forEach((value: string, key: string) => {
|
|
60
|
+
headerLines.push(`${key}: ${value}\r\n`);
|
|
61
|
+
});
|
|
62
|
+
headerLines.push('\r\n');
|
|
63
|
+
|
|
64
|
+
const textEncoder = new TextEncoder();
|
|
65
|
+
const headerChunk = headerLines.join('');
|
|
66
|
+
const bodyReader = body ? body.getReader() : undefined;
|
|
67
|
+
|
|
68
|
+
let headerEnqueued = false;
|
|
69
|
+
let eof = false;
|
|
70
|
+
const readableHeaderChunkedBody = new ReadableStream<Uint8Array>({
|
|
71
|
+
async pull(controller: any) {
|
|
72
|
+
if (!headerEnqueued) {
|
|
73
|
+
controller.enqueue(headerChunk);
|
|
74
|
+
headerEnqueued = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (eof) {
|
|
78
|
+
controller.close();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!bodyReader) {
|
|
82
|
+
controller.close();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const chunk = await bodyReader.read();
|
|
86
|
+
if (chunk.value) {
|
|
87
|
+
let outputChunk;
|
|
88
|
+
if (contentLength) {
|
|
89
|
+
outputChunk = chunk.value;
|
|
90
|
+
} else {
|
|
91
|
+
outputChunk = Buffer.concat([
|
|
92
|
+
textEncoder.encode(`${chunk.value.length.toString(16)}\r\n`),
|
|
93
|
+
chunk.value,
|
|
94
|
+
textEncoder.encode(`\r\n`),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
controller.enqueue(outputChunk);
|
|
98
|
+
} else if (chunk.done) {
|
|
99
|
+
if (!contentLength) {
|
|
100
|
+
controller.enqueue(Buffer.from(`0\r\n\r\n`, 'utf8'));
|
|
101
|
+
}
|
|
102
|
+
eof = true;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const readableHeaderChunkedBodyReader =
|
|
108
|
+
readableHeaderChunkedBody.getReader();
|
|
109
|
+
const pipeChunk = async () => {
|
|
110
|
+
while (true) {
|
|
111
|
+
try {
|
|
112
|
+
const chunk = await readableHeaderChunkedBodyReader.read();
|
|
113
|
+
if (chunk.value) {
|
|
114
|
+
if (!client.write(chunk.value)) {
|
|
115
|
+
// interrupt this loop and wait for "drain" event
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
} else if (chunk.done) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
logger.error(e);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
client.on('drain', pipeChunk);
|
|
128
|
+
await pipeChunk();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|