Skip to content
File

Blob: src/cloudflare/internal/streaming-forms.ts

typescript133 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5const CRLF = '\r\n';
6 
7function isReadableStream(obj: unknown): obj is ReadableStream {
8 return !!(
9 obj &&
10 typeof obj === 'object' &&
11 'getReader' in obj &&
12 typeof obj.getReader === 'function'
13 );
14}
15 
16function chainStreams<T>(streams: ReadableStream<T>[]): ReadableStream<T> {
17 const outputStream = new ReadableStream<T>({
18 async start(controller): Promise<void> {
19 for (const stream of streams) {
20 const reader = stream.getReader();
21 
22 try {
23 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
24 while (true) {
25 const { done, value } = await reader.read();
26 if (done) break;
27 if (value !== undefined) controller.enqueue(value);
28 }
29 } finally {
30 reader.releaseLock();
31 }
32 }
33 
34 controller.close();
35 },
36 });
37 
38 return outputStream;
39}
40 
41type EntryOptions = { type: 'file' | 'string' };
42 
43export class StreamableFormData {
44 #entries: {
45 field: string;
46 value: ReadableStream;
47 options: EntryOptions;
48 }[];
49 #boundary: string;
50 
51 constructor() {
52 this.#entries = [];
53 
54 this.#boundary = '--------------------------';
55 for (let i = 0; i < 24; i++) {
56 this.#boundary += Math.floor(Math.random() * 10).toString(16);
57 }
58 }
59 
60 append(
61 field: string,
62 value: ReadableStream | string,
63 options?: EntryOptions
64 ): void {
65 let valueStream: ReadableStream;
66 if (isReadableStream(value)) {
67 valueStream = value;
68 } else {
69 valueStream = new Blob([value]).stream();
70 }
71 
72 this.#entries.push({
73 field,
74 value: valueStream,
75 options: options || { type: 'string' },
76 });
77 }
78 
79 #multipartBoundary(): ReadableStream {
80 return new Blob(['--', this.#boundary, CRLF]).stream();
81 }
82 
83 #multipartHeader(name: string, type: 'file' | 'string'): ReadableStream {
84 let filenamePart;
85 
86 if (type === 'file') {
87 filenamePart = `; filename="${name}"`;
88 } else {
89 filenamePart = '';
90 }
91 
92 return new Blob([
93 `content-disposition: form-data; name="${name}"${filenamePart}`,
94 CRLF,
95 CRLF,
96 ]).stream();
97 }
98 
99 #multipartBody(stream: ReadableStream): ReadableStream {
100 return chainStreams([stream, new Blob([CRLF]).stream()]);
101 }
102 
103 #multipartFooter(): ReadableStream {
104 return new Blob(['--', this.#boundary, '--', CRLF]).stream();
105 }
106 
107 contentType(): string {
108 return `multipart/form-data; boundary=${this.#boundary}`;
109 }
110 
111 stream(): ReadableStream {
112 const streams: ReadableStream[] = [this.#multipartBoundary()];
113 
114 const valueStreams = [];
115 for (const { field, value, options } of this.#entries) {
116 valueStreams.push(this.#multipartHeader(field, options.type));
117 valueStreams.push(this.#multipartBody(value));
118 valueStreams.push(this.#multipartBoundary());
119 }
120 
121 if (valueStreams.length) {
122 // Remove last boundary as we want a footer instead
123 valueStreams.pop();
124 }
125 
126 streams.push(...valueStreams);
127 
128 streams.push(this.#multipartFooter());
129 
130 return chainStreams(streams);
131 }
132}