Skip to content

Commit 460b7cc

Browse files
committed
WIP - ReadableStream for multipart
1 parent 6911fa8 commit 460b7cc

2 files changed

Lines changed: 113 additions & 91 deletions

File tree

src/lib/client/MultipartHttpClient.ts

Lines changed: 103 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,136 @@
11
import { HttpMethod } from 'simple-http-request-builder';
2-
import { HttpPromise, unwrapHttpPromise } from '../promise/HttpPromise';
3-
import { networkErrorCatcher } from './FetchClient';
42
import {
5-
genericError, HttpResponse, networkError, timeoutError,
6-
} from './HttpResponse';
3+
FetchResponseHandler,
4+
genericError,
5+
HttpPromise,
6+
HttpResponse,
7+
networkErrorCatcher,
8+
toErrorResponsePromise,
9+
unwrapHttpPromise,
10+
} from 'simple-http-rest-client';
11+
import { Logger } from 'simple-logging-system';
712
import {
813
MultipartHttpClient,
914
MultipartHttpOptions,
1015
MultipartHttpRequest,
1116
} from './MultipartHttpRequest';
1217

13-
/**
14-
* Handle multipart request using {@link XMLHttpRequest}
15-
* @param multipartHttpRequest the request to be executed
16-
*/
18+
const logger: Logger = new Logger('MultipartHttpClient');
19+
1720
export const multipartHttpFetchClientExecutor: MultipartHttpClient<Promise<unknown>> = (
1821
multipartHttpRequest: MultipartHttpRequest<unknown>,
19-
): Promise<unknown> => {
20-
const xhr: XMLHttpRequest = new XMLHttpRequest();
21-
22-
// Abort request after configured timeout time
23-
const timeoutHandle: ReturnType<typeof setTimeout> = setTimeout(
24-
() => xhr.abort(),
25-
multipartHttpRequest.optionValues.timeoutInMillis,
26-
);
27-
28-
// Return a promise that resolves when the request is complete
29-
return new Promise<unknown>((resolve: (value: unknown) => void) => {
30-
xhr.open(multipartHttpRequest.method, multipartHttpRequest.buildUrl(), true);
31-
32-
// Set credentials
33-
xhr.withCredentials = multipartHttpRequest.optionValues.withCredentials;
34-
35-
// Set headers
36-
if (multipartHttpRequest.headersValue) {
37-
for (const [key, value] of Object.entries(multipartHttpRequest.headersValue)) {
38-
xhr.setRequestHeader(key, value);
39-
}
40-
}
22+
): Promise<Response> => {
23+
const { boundary }: MultipartHttpOptions = multipartHttpRequest.optionValues;
24+
const stream: ReadableStream<unknown> = new ReadableStream({
25+
start(controller: ReadableStreamDefaultController<unknown>) {
26+
const encoder: TextEncoder = new TextEncoder();
27+
const entries = Array.from(multipartHttpRequest.formData.entries());
28+
const processNextEntry = (index: number) => {
29+
if (index >= entries.length) {
30+
// Finalize the stream with the closing boundary
31+
controller.enqueue(encoder.encode(`--${boundary}--\r\n`));
32+
controller.close();
33+
return;
34+
}
4135

42-
// Handle response
43-
xhr.onload = () => {
44-
if (xhr.status >= 200 && xhr.status < 300) {
45-
return resolve({ response: JSON.parse(xhr.response) });
46-
}
47-
return resolve({ error: JSON.parse(xhr.response) ?? genericError });
48-
};
36+
const [name, data] = entries[index];
37+
const enqueueBoundaryAndHeaders = () => {
38+
controller.enqueue(encoder.encode(`--${boundary}\r\n`));
39+
if (data instanceof Blob) {
40+
// Binary data
41+
const contentType = data.type || 'application/octet-stream';
42+
controller.enqueue(encoder.encode(
43+
`Content-Disposition: form-data; name="${name}"; filename="${name}"\r\n`
44+
+ `Content-Type: ${contentType}\r\n\r\n`,
45+
));
46+
return data.stream().getReader();
47+
}
48+
// Text data
49+
controller.enqueue(encoder.encode(
50+
`Content-Disposition: form-data; name="${name}"\r\n\r\n`,
51+
));
52+
controller.enqueue(encoder.encode(`${data.toString()}\r\n`));
53+
return null;
54+
};
4955

50-
// Handle network errors
51-
xhr.onerror = () => resolve({ error: networkError });
56+
const reader = enqueueBoundaryAndHeaders();
5257

53-
// Handle request timeout
54-
xhr.ontimeout = () => resolve({ error: timeoutError });
58+
// Handle text directly, or start processing binary data
59+
if (!reader) {
60+
processNextEntry(index + 1); // Process the next entry
61+
return;
62+
}
5563

56-
// Handle progress
57-
xhr.upload.onprogress = (event: ProgressEvent) => {
58-
multipartHttpRequest.optionValues.onProgressCallback(event);
59-
};
64+
// Process the binary data with recursion
65+
const pumpReader = () => {
66+
reader.read().then(({ done, value }) => {
67+
if (done) {
68+
// Move to the next entry
69+
controller.enqueue(encoder.encode('\r\n'));
70+
processNextEntry(index + 1);
71+
} else {
72+
// Enqueue the current chunk
73+
controller.enqueue(value);
74+
pumpReader(); // Process the next chunk
75+
}
76+
});
77+
};
6078

61-
xhr.upload.onerror = () => resolve({ error: genericError });
79+
pumpReader();
80+
};
6281

63-
// Send the request
64-
xhr.send(multipartHttpRequest.formData);
65-
})
82+
processNextEntry(0); // Start processing the first entry
83+
},
84+
cancel: (reason: unknown) => {
85+
multipartHttpRequest.optionValues.timeoutAbortController.abort(reason);
86+
},
87+
});
88+
const timeoutHandle = setTimeout(
89+
() => stream.cancel('timeout'),
90+
multipartHttpRequest.optionValues.timeoutInMillis,
91+
);
92+
return fetch(
93+
multipartHttpRequest.buildUrl(),
94+
{
95+
headers: {
96+
...multipartHttpRequest.headersValue,
97+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
98+
},
99+
method: HttpMethod.POST,
100+
body: stream,
101+
signal: multipartHttpRequest.optionValues.timeoutAbortController.signal,
102+
duplex: 'half',
103+
},
104+
)
66105
.finally(() => clearTimeout(timeoutHandle));
67106
};
68107

69-
/**
70-
* A {@link MultipartHttpFetchClient} that executes an {@link MultipartHttpRequest} that returns JSON responses.
71-
* It uses {@link multipartHttpFetchClient} to executes the {@link MultipartHttpRequest}.
72-
*/
73108
export const multipartHttpFetchClient = <T = void>(
74109
httpRequest: MultipartHttpRequest<unknown>,
110+
...handlers: FetchResponseHandler[]
75111
): Promise<HttpResponse<T>> => <Promise<HttpResponse<T>>>multipartHttpFetchClientExecutor(httpRequest)
112+
.then((response) => {
113+
for (const handler of handlers) {
114+
try {
115+
const handlerResult = handler(response);
116+
if (handlerResult !== undefined) {
117+
return handlerResult;
118+
}
119+
} catch (error) {
120+
logger.error('Error executing handler', { error });
121+
return toErrorResponsePromise(genericError);
122+
}
123+
}
124+
return { response };
125+
})
76126
.catch(networkErrorCatcher);
77127

78128
export type MultipartHttpFetchClient = <T>(
79129
multipartHttpRequest: MultipartHttpRequest<unknown>,
80130
) => Promise<HttpResponse<T>>;
81131

82-
/**
83-
* Factory function to create fetch {@link MultipartHttpRequest}.
84-
*
85-
* @param baseUrl The base URL. It should not contain an ending slash. A valid base URL is: http://hostname/api
86-
* @param method The HTTP method used for the request, see {@link HttpMethod}
87-
* @param path The path of the endpoint to call, it should be composed with a leading slash
88-
* and will be appended to the {@link MultipartHttpRequest#baseUrl}. A valid path is: /users
89-
* @param multipartHttpClient The fetch client that uses {@link MultipartHttpRequest} and returns
90-
* a `Promise<HttpResponse<T>>`
91-
* @param options Optional options to configure the request
92-
*/
93132
export function createMultipartHttpFetchRequest<T>(
94133
baseUrl: string,
95-
method: HttpMethod,
96134
path: string,
97135
multipartHttpClient: MultipartHttpFetchClient,
98136
options?: Partial<MultipartHttpOptions>,
@@ -103,7 +141,6 @@ export function createMultipartHttpFetchRequest<T>(
103141
multipartHttpRequest,
104142
),
105143
baseUrl,
106-
method,
107144
path,
108145
options,
109146
);

src/lib/client/MultipartHttpRequest.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
1-
import { HttpMethod } from 'simple-http-request-builder';
2-
31
export type MultipartHttpOptions = {
42
timeoutInMillis: number,
3+
timeoutAbortController: AbortController,
54
onProgressCallback: (event: ProgressEvent) => void,
6-
withCredentials: boolean,
5+
boundary: string,
76
};
87

9-
export type MultipartHttpClient<T> = (request: MultipartHttpRequest<unknown>) => T;
8+
export interface MultipartHttpClient<T> {
9+
(request: MultipartHttpRequest<unknown>): T;
10+
}
1011

1112
export class MultipartHttpRequest<T> {
12-
private static readonly DEFAULT_TIMEOUT_IN_MILLIS: number = 60_000; // 1 minute
13-
1413
readonly multipartHttpClient: MultipartHttpClient<T>;
1514

1615
readonly baseUrl: URL;
1716

18-
readonly method: HttpMethod;
19-
2017
readonly path: string;
2118

2219
readonly headersValue: HeadersInit;
@@ -28,20 +25,20 @@ export class MultipartHttpRequest<T> {
2825
constructor(
2926
multipartHttpClient: MultipartHttpClient<T>,
3027
baseUrl: string,
31-
method: HttpMethod,
3228
path: string,
3329
options?: Partial<MultipartHttpOptions>,
3430
) {
3531
this.multipartHttpClient = multipartHttpClient;
3632
this.baseUrl = new URL(baseUrl);
37-
this.method = method;
3833
this.path = path;
3934
this.headersValue = {};
4035
this.formData = new FormData();
4136
this.optionValues = {
42-
timeoutInMillis: options?.timeoutInMillis ?? MultipartHttpRequest.DEFAULT_TIMEOUT_IN_MILLIS,
43-
onProgressCallback: options?.onProgressCallback ?? (() => {}),
44-
withCredentials: options?.withCredentials ?? false,
37+
timeoutInMillis: options?.timeoutInMillis ?? 60000,
38+
timeoutAbortController: options?.timeoutAbortController ?? new AbortController(),
39+
onProgressCallback: options?.onProgressCallback ?? (() => {
40+
}),
41+
boundary: options?.boundary ?? `boundary-${Date.now()}`,
4542
};
4643
}
4744

@@ -59,18 +56,6 @@ export class MultipartHttpRequest<T> {
5956
return this;
6057
}
6158

62-
file(file: File) {
63-
this.data([['file', file]]);
64-
return this;
65-
}
66-
67-
files(files: File[]) {
68-
for (const file of files) {
69-
this.file(file);
70-
}
71-
return this;
72-
}
73-
7459
buildUrl() {
7560
return encodeURI(this.baseUrl.toString() + this.path);
7661
}

0 commit comments

Comments
 (0)