Skip to content

Commit 2a75f8b

Browse files
committed
feat: support many types of files
1 parent 621db1d commit 2a75f8b

2 files changed

Lines changed: 140 additions & 24 deletions

File tree

lib/CloudConvert.ts

Lines changed: 132 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { io, type Socket } from 'socket.io-client';
2+
import { Readable } from 'node:stream';
23
import { version } from '../package.json';
34
import JobsResource, { type JobEventData } from './JobsResource';
45
import SignedUrlResource from './SignedUrlResource';
@@ -9,6 +10,85 @@ import TasksResource, {
910
import UsersResource from './UsersResource';
1011
import WebhooksResource from './WebhooksResource';
1112

13+
export type UploadFileSource =
14+
| Blob
15+
| Uint8Array
16+
| Iterable<Uint8Array>
17+
| AsyncIterable<Uint8Array>
18+
| NodeJS.ReadableStream;
19+
20+
export class UploadFile {
21+
private readonly attributes: Array<[key: string, value: unknown]> = [];
22+
private readonly data: AsyncIterable<Uint8Array>;
23+
constructor(
24+
data: UploadFileSource,
25+
private readonly filename?: string
26+
) {
27+
this.data = UploadFile.unifySources(data);
28+
}
29+
add(key: string, value: unknown) {
30+
this.attributes.push([key, value]);
31+
}
32+
async *stream() {
33+
const enc = new TextEncoder();
34+
const boundary = `----------${Array.from(Array(32))
35+
.map(() => Math.random().toString(36)[2] || 0)
36+
.join('')}`;
37+
// Start multipart/form-data protocol
38+
yield enc.encode(`--${boundary}\r\n`);
39+
// Send all attributes
40+
const separator = enc.encode(`\r\n--${boundary}\r\n`);
41+
let first = true;
42+
for (const [key, value] of this.attributes) {
43+
if (value == null) continue;
44+
if (!first) yield separator;
45+
yield enc.encode(
46+
`content-disposition:form-data;name="${key}"\r\n\r\n${value}`
47+
);
48+
first = false;
49+
}
50+
// Send file
51+
if (!first) yield separator;
52+
yield enc.encode(
53+
`content-disposition:form-data;name="file";filename=${this.filename}\r\ncontent-type:application/octet-stream\r\n\r\n`
54+
);
55+
yield* this.data;
56+
// End multipart/form-data protocol
57+
yield enc.encode(`\r\n--${boundary}--\r\n`);
58+
}
59+
60+
static async *unifySources(
61+
data: UploadFileSource
62+
): AsyncIterable<Uint8Array> {
63+
if (data instanceof Uint8Array) {
64+
yield data;
65+
return;
66+
}
67+
68+
if (data instanceof Blob) {
69+
yield data.bytes();
70+
return;
71+
}
72+
73+
if (Symbol.iterator in data) {
74+
yield* data;
75+
return;
76+
}
77+
78+
if (Symbol.asyncIterator in data) {
79+
const it = data[Symbol.asyncIterator]();
80+
for await (const chunk of data) {
81+
if (typeof chunk === 'string')
82+
throw new Error(
83+
'bad file data, received string but expected Uint8Array'
84+
);
85+
yield chunk;
86+
}
87+
return;
88+
}
89+
}
90+
}
91+
1292
export default class CloudConvert {
1393
private socket: Socket | undefined;
1494
private subscribedChannels: Map<string, boolean> | undefined;
@@ -38,7 +118,7 @@ export default class CloudConvert {
38118
async call(
39119
method: 'GET' | 'POST' | 'DELETE',
40120
route: string,
41-
parameters?: FormData | object
121+
parameters?: UploadFile | object
42122
) {
43123
const baseURL = this.useSandbox
44124
? 'https://api.sandbox.cloudconvert.com/v2/'
@@ -52,28 +132,27 @@ export default class CloudConvert {
52132
baseURL: string,
53133
method: 'GET' | 'POST' | 'DELETE',
54134
route: string,
55-
parameters?: FormData | object
135+
parameters?: UploadFile | object
56136
) {
57137
const url = new URL(route, baseURL);
58-
let body: RequestInit['body'] | undefined;
59-
if (parameters instanceof FormData) {
60-
body = parameters;
61-
} else {
62-
if (method === 'GET') {
63-
url.search = new URLSearchParams(
64-
Object.entries(parameters ?? {})
65-
).toString();
66-
} else {
67-
body = JSON.stringify(parameters);
68-
}
138+
const { contentType, search, body } = prepareParameters(
139+
method,
140+
parameters
141+
);
142+
if (search !== undefined) {
143+
url.search = search;
69144
}
145+
const headers = {
146+
Authorization: `Bearer ${this.apiKey}`,
147+
'User-Agent': `cloudconvert-node/v${version} (https://github.com/cloudconvert/cloudconvert-node)`,
148+
...(contentType ? { 'Content-Type': contentType } : {})
149+
};
70150
const res = await fetch(url, {
71151
method,
72-
headers: {
73-
Authorization: `Bearer ${this.apiKey}`,
74-
'User-Agent': `cloudconvert-node/v${version} (https://github.com/cloudconvert/cloudconvert-node)`
75-
},
76-
body
152+
headers,
153+
body,
154+
// @ts-expect-error incorrect types in @types/node@20
155+
duplex: 'half'
77156
});
78157
if (
79158
!res.ok ||
@@ -127,3 +206,38 @@ export default class CloudConvert {
127206
this.socket?.close();
128207
}
129208
}
209+
210+
function prepareParameters(
211+
method: 'GET' | 'POST' | 'DELETE',
212+
data?: UploadFile | object
213+
): {
214+
contentType?: string;
215+
body?: string | ReadableStream<Uint8Array>;
216+
search?: string;
217+
} {
218+
if (data === undefined) {
219+
return {};
220+
}
221+
222+
if (method === 'GET') {
223+
// abort early if all data needs to go into the search params
224+
const entries = Object.entries(data ?? {});
225+
return { search: new URLSearchParams(entries).toString() };
226+
}
227+
228+
if (data instanceof UploadFile) {
229+
return {
230+
contentType: 'multipart/form-data',
231+
body: asyncIterableToReadableStream(data.stream())
232+
};
233+
}
234+
235+
return { contentType: 'application/json', body: JSON.stringify(data) };
236+
}
237+
238+
function asyncIterableToReadableStream(
239+
it: AsyncIterable<Uint8Array>
240+
): ReadableStream<Uint8Array> {
241+
const r = Readable.from(it);
242+
return Readable.toWeb(r) as ReadableStream<Uint8Array>;
243+
}

lib/TasksResource.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import CloudConvert from './CloudConvert';
1+
import CloudConvert, {
2+
UploadFile,
3+
type UploadFileSource
4+
} from './CloudConvert';
25
import { type JobTask } from './JobsResource';
36

47
export type TaskEvent = 'created' | 'updated' | 'finished' | 'failed';
@@ -587,7 +590,7 @@ export default class TasksResource {
587590

588591
async upload(
589592
task: Task | JobTask,
590-
stream: Blob,
593+
stream: UploadFileSource,
591594
filename?: string
592595
): Promise<any> {
593596
if (task.operation !== 'import/upload') {
@@ -598,16 +601,15 @@ export default class TasksResource {
598601
throw new Error('The task is not ready for uploading');
599602
}
600603

601-
const formData = new FormData();
604+
const uploadFile = new UploadFile(stream, filename);
602605
for (const parameter in task.result.form.parameters) {
603-
formData.append(parameter, task.result.form.parameters[parameter]);
606+
uploadFile.add(parameter, task.result.form.parameters[parameter]);
604607
}
605-
formData.append('file', stream, filename);
606608

607609
return await this.cloudConvert.call(
608610
'POST',
609611
task.result.form.url,
610-
formData
612+
uploadFile
611613
);
612614
}
613615

0 commit comments

Comments
 (0)