• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

panates / opra / 29494915195

16 Jul 2026 11:33AM UTC coverage: 83.024% (+0.4%) from 82.653%
29494915195

push

github

erayhanoglu
1.29.1

4118 of 5238 branches covered (78.62%)

Branch coverage included in aggregate %.

34348 of 41093 relevant lines covered (83.59%)

239.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.77
/packages/http/src/impl/multipart-reader.ts
1
import fs from 'node:fs';
1✔
2
import os from 'node:os';
1✔
3
import typeIs from '@browsery/type-is';
1✔
4
import { BadRequestError, HttpMediaType } from '@opra/common';
1✔
5
import fsPromise from 'fs/promises';
1✔
6
import * as MP from 'multipasta/node';
1✔
7
import { AsyncEventEmitter } from 'node-events-async';
1✔
8
import type { StrictOmit } from 'ts-gems';
1✔
9
import { isNotNullish, ValidationError } from 'valgen';
1✔
10
import type { HttpRequest } from '../interfaces/http-request.interface.js';
1✔
11
import { LocalFile } from './local-file.js';
1✔
12

1✔
13
/**
1✔
14
 * MultipartReader is a drop-in replacement for MultipartReader that uses
1✔
15
 * `multipasta` instead of `busboy`. The key advantage: part-level headers
1✔
16
 * (e.g. `Request-Id`) are fully exposed via `item.headers`.
16✔
17
 */
16✔
18
export class MultipartReader extends AsyncEventEmitter {
16✔
19
  protected _started = false;
16✔
20
  protected _streamClosed = false;
16✔
21
  protected _finished = false;
1✔
22
  protected _cancelled = false;
16✔
23
  protected _pendingWrites = 0;
16✔
24
  protected _stream: MP.MultipastaStream;
16✔
25
  protected _items: MultipartReader.Item[] = [];
16✔
26
  protected _stack: MultipartReader.Item[] = [];
16✔
27
  protected tempDirectory: string;
16✔
28
  scope?: string;
16✔
29

16✔
30
  constructor(
16✔
31
    protected request: HttpRequest,
16✔
32
    options?: MultipartReader.Options,
16✔
33
    protected mediaType?: HttpMediaType,
16✔
34
  ) {
16✔
35
    super();
16✔
36
    this.setMaxListeners(1000);
16✔
37
    this.tempDirectory = options?.tempDirectory || os.tmpdir();
×
38
    this.scope = options?.scope;
×
39

×
40
    const stream = MP.make({
×
41
      headers: request.headers as Record<string, string>,
16✔
42
      isFile: options?.isFile,
16✔
43
    });
16✔
44
    this._stream = stream;
16✔
45

16✔
46
    stream.once('error', (e: any) => {
16✔
47
      this._cancelled = true;
16✔
48
      this._finished = true;
16✔
49
      if (this.listenerCount('error') > 0) this.emit('error', e);
16✔
50
    });
16✔
51

16✔
52
    // Stream closed means the parser is done, but file writes may still be in
16✔
53
    // progress. _finished is set only when stream closes AND all writes complete.
5✔
54
    stream.on('close', () => {
5✔
55
      this._streamClosed = true;
5✔
56
      this._checkFinished();
5✔
57
    });
5✔
58

5✔
59
    stream.on('field', (part: MP.Field) => {
5✔
60
      const headers = _flattenHeaders(part.info.headers);
5✔
61
      const item: MultipartReader.Field = {
5✔
62
        kind: 'field',
5✔
63
        field: part.info.name,
5✔
64
        value: MP.decodeField(part.info, part.value),
5✔
65
        mimeType: part.info.contentType,
16✔
66
        encoding: part.info.contentTypeParameters['charset'],
16✔
67
        headers,
22✔
68
      };
22✔
69
      this._items.push(item);
22✔
70
      this._stack.push(item);
22✔
71
      this.emit('item', item);
22✔
72
    });
22✔
73

22✔
74
    stream.on('file', (file: MP.FileStream) => {
22✔
75
      const saveTo = LocalFile.tempFilename(
22✔
76
        file.info.filename ?? file.info.name,
22✔
77
        this.tempDirectory,
22✔
78
      );
22✔
79
      const writeStream = fs.createWriteStream(saveTo);
22✔
80
      file.pipe(writeStream);
22✔
81

22✔
82
      // Build a "ready" promise that resolves when the write is fully flushed.
22✔
83
      // buffer() / text() await this so callers always read a complete file.
22✔
84
      const ready = new Promise<void>(resolve =>
22✔
85
        writeStream.once('finish', resolve),
22✔
86
      );
22✔
87

22✔
88
      // Emit the item immediately (preserves part order) — the write may
22✔
89
      // still be in progress, but reading is deferred via ready.
22✔
90
      const headers = _flattenHeaders(file.info.headers);
22✔
91
      const item = new MultipartFile(file.info.name, saveTo, ready, {
22✔
92
        filename: file.info.filename ?? file.info.name,
22✔
93
        type: file.info.contentType,
22✔
94
        encoding: (file.info.contentTypeParameters['charset'] ??
22✔
95
          'utf-8') as BufferEncoding,
22✔
96
        autoDelete: true,
22✔
97
        headers,
22✔
98
      });
22✔
99
      this._items.push(item);
22✔
100
      this._stack.push(item);
22✔
101
      this.emit('item', item);
1✔
102

1✔
103
      this._pendingWrites++;
38✔
104
      ready.then(() => {
38✔
105
        this._pendingWrites--;
38✔
106
        this._checkFinished();
16✔
107
      });
16✔
108
    });
16✔
109
  }
16✔
110

16✔
111
  protected _checkFinished() {
1✔
112
    if (this._streamClosed && this._pendingWrites === 0) {
1✔
113
      this._finished = true;
1✔
114
      this.emit('_finished');
1✔
115
    }
1✔
116
  }
1✔
117

1✔
118
  get items(): MultipartReader.Item[] {
1✔
119
    return this._items;
41✔
120
  }
41✔
121

41✔
122
  /**
41✔
123
   * Retrieves the next item (field or file) from the multipart stream.
41✔
124
   */
20✔
125
  async getNext(): Promise<MultipartReader.Item | undefined> {
20✔
126
    let item = this._stack.shift();
20✔
127
    if (!item && !this._finished) {
20!
128
      this.resume();
20✔
129
      item = await new Promise<any>((resolve, reject) => {
20!
130
        let resolved = false;
20✔
131
        if (this._stack.length) return resolve(this._stack.shift());
20!
132
        if (this._finished) return resolve(this._stack.shift());
4✔
133
        const onDone = () => {
20✔
134
          if (resolved) return;
20✔
135
          resolved = true;
20✔
136
          resolve(this._stack.shift());
20✔
137
        };
20✔
138
        // _finished fires only after stream closes AND all file writes complete
20✔
139
        this.once('_finished', onDone);
16✔
140
        this.once('item', () => {
16✔
141
          this.pause();
16✔
142
          this.removeListener('_finished', onDone);
16!
143
          if (resolved) return;
16✔
144
          resolved = true;
16✔
145
          resolve(this._stack.shift());
20✔
146
        });
20✔
147
        this.once('error', e => reject(e));
20✔
148
      });
41✔
149
    }
41✔
150

41✔
151
    if (item && this.mediaType) {
41✔
152
      const field = this.mediaType.findMultipartField(item.field);
5✔
153
      if (!field)
5✔
154
        throw new BadRequestError(`Unknown multipart field (${item.field})`);
5!
155
      if (item.kind === 'field') {
5✔
156
        const decode = field.generateCodec('decode', {
3✔
157
          scope: this.scope,
3✔
158
          ignoreReadonlyFields: true,
3✔
159
          projection: '*',
3✔
160
        });
3✔
161
        item!.value = decode(item!.value, {
3✔
162
          onFail: issue =>
3✔
163
            `Multipart field (${item.field}) validation failed: ` +
3✔
164
            issue.message,
×
165
        });
×
166
        this.emit('field', item);
3✔
167
      } else if (item.kind === 'file') {
3✔
168
        if (field.contentType) {
5✔
169
          const arr = Array.isArray(field.contentType)
2✔
170
            ? field.contentType
2✔
171
            : [field.contentType];
2✔
172
          if (!(item.type && arr.find(ct => typeIs.is(item.type!, [ct])))) {
2!
173
            throw new BadRequestError(
2✔
174
              `Multipart field (${item.field}) do not accept this content type`,
2✔
175
            );
2✔
176
          }
2✔
177
        }
2✔
178
        this.emit('file', item);
2!
179
      }
×
180
    }
×
181

×
182
    /* if all items received we check for required items */
×
183
    if (
×
184
      this._finished &&
×
185
      this.mediaType &&
2✔
186
      this.mediaType.multipartFields?.length > 0
2✔
187
    ) {
5✔
188
      const fieldsLeft = new Set(this.mediaType.multipartFields);
41✔
189
      for (const x of this._items) {
41✔
190
        const field = this.mediaType.findMultipartField(x.field);
41✔
191
        if (field) fieldsLeft.delete(field);
3✔
192
      }
3✔
193
      let error: ValidationError | undefined;
3✔
194
      for (const field of fieldsLeft) {
8✔
195
        if (!field.required) continue;
8✔
196
        try {
8✔
197
          isNotNullish(null, {
8✔
198
            onFail: () =>
3✔
199
              `Multi part field "${String(field.fieldName)}" is required`,
3✔
200
          });
1✔
201
        } catch (e: any) {
1!
202
          if (!error) {
×
203
            error = e;
×
204
          } else
×
205
            (error as ValidationError).issues.push(
×
206
              ...(e as ValidationError).issues,
×
207
            );
×
208
        }
×
209
      }
×
210
      if (error) {
×
211
        this.emit('error', error);
×
212
        throw error;
×
213
      }
×
214
    }
×
215
    return item;
×
216
  }
×
217

×
218
  /**
×
219
   * Retrieves all items from the multipart stream.
3✔
220
   */
3✔
221
  async getAll(): Promise<MultipartReader.Item[]> {
3!
222
    const items: MultipartReader.Item[] = [...this._items];
3✔
223
    let item: MultipartReader.Item | undefined;
1✔
224
    while (!this._cancelled && (item = await this.getNext())) {
1✔
225
      items.push(item);
5✔
226
    }
5✔
227
    return items;
5✔
228
  }
5✔
229

5✔
230
  cancel() {
5✔
231
    this._cancelled = true;
5✔
232
    if (this._started) this.resume();
5✔
233
  }
14✔
234

14✔
235
  resume() {
5✔
236
    if (!this._started) {
9✔
237
      this._started = true;
5✔
238
      this.request.pipe(this._stream);
1✔
239
      // Drain the readable side of the Duplex so 'end' → 'close' can fire.
1✔
240
      this._stream.resume();
33✔
241
    }
33✔
242
    this.request.resume();
16✔
243
  }
16✔
244

16✔
245
  pause() {
16✔
246
    this.request.pause();
16✔
247
  }
16✔
248

16✔
249
  /**
16✔
250
   * Purges all temporary files created by the reader.
16✔
251
   */
16✔
252
  async purge() {
16✔
253
    const promises: Promise<any>[] = [];
16✔
254
    this._items.forEach(item => {
33✔
255
      if (item.kind !== 'file') return;
1✔
256
      promises.push(fsPromise.unlink(item.storedPath).catch(() => {}));
1✔
257
    });
1✔
258
    return Promise.allSettled(promises);
1✔
259
  }
6✔
260
}
6✔
261

6✔
262
/**
6✔
263
 *
6✔
264
 * @class
6✔
265
 */
6✔
266
class MultipartFile extends LocalFile {
6✔
267
  readonly kind = 'file';
9✔
268
  readonly field: string;
8✔
269
  readonly headers: Record<string, string>;
8✔
270
  private readonly _ready: Promise<void>;
6✔
271

6✔
272
  constructor(
6✔
273
    field: string,
1✔
274
    storedPath: string,
1✔
275
    ready: Promise<void>,
1✔
276
    options: LocalFile.Options & { headers?: Record<string, string> } = {
1✔
277
      autoDelete: true,
1✔
278
    },
22✔
279
  ) {
22✔
280
    super(storedPath, options);
22✔
281
    this.field = field;
22✔
282
    this.headers = options.headers ?? {};
22✔
283
    this._ready = ready;
22✔
284
  }
22✔
285

22✔
286
  async text(): Promise<string> {
22!
287
    await this._ready;
22✔
288
    return super.text();
1✔
289
  }
3✔
290

3✔
291
  async buffer(): Promise<Buffer> {
3✔
292
    await this._ready;
3✔
293
    return super.buffer();
1✔
294
  }
14✔
295
}
14✔
296

14✔
297
/**
14✔
298
 *
14✔
299
 * @namespace
14✔
300
 */
14✔
301
export namespace MultipartReader {
14✔
302
  export interface Options extends StrictOmit<
1✔
303
    MP.NodeConfig,
27✔
304
    'headers' | 'isFile'
27✔
305
  > {
27✔
306
    tempDirectory?: string;
27✔
307
    scope?: string;
67✔
308
    isFile?: (info: MP.PartInfo) => boolean;
1!
309
  }
1✔
310

1✔
311
  export interface Field {
1✔
312
    kind: 'field';
1✔
313
    field: string;
1✔
314
    value?: any;
1✔
315
    mimeType?: string;
1✔
316
    encoding?: string;
1✔
317
    headers?: Record<string, string>;
1✔
318
  }
1✔
319

1✔
320
  export type File = MultipartFile;
1✔
321

1✔
322
  export type Item = Field | File;
1✔
323
}
1✔
324

1✔
325
function _flattenHeaders(
1✔
326
  headers: Record<string, string | string[]>,
1✔
327
): Record<string, string> {
1✔
328
  const out: Record<string, string> = {};
1✔
329
  for (const [k, v] of Object.entries(headers)) {
1✔
330
    out[k] = Array.isArray(v) ? v.join(', ') : v;
1✔
331
  }
1✔
332
  return out;
1✔
333
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc