Skip to content

Commit ecea6db

Browse files
committed
Support ArrayBuffer, TypedArray, DataView, Blob and Web Streams in every environment
1 parent d658d01 commit ecea6db

14 files changed

+165
-88
lines changed

lib/browser/BrowserFileReader.ts

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js'
22
import { uriToBlob } from '../reactnative/uriToBlob.js'
33

4+
import {
5+
openFile as openBaseFile,
6+
supportedTypes as supportedBaseTypes,
7+
} from '../commonFileReader.js'
48
import type { FileReader, FileSource, UploadInput } from '../options.js'
5-
import { BlobFileSource } from './sources/BlobFileSource.js'
6-
import { StreamFileSource } from './sources/StreamFileSource.js'
9+
import { BlobFileSource } from '../sources/BlobFileSource.js'
710

8-
function isWebStream(input: UploadInput): input is Pick<ReadableStreamDefaultReader, 'read'> {
9-
return 'read' in input && typeof input.read === 'function'
10-
}
11-
12-
// TODO: Make sure that we support ArrayBuffers, TypedArrays, DataViews and Blobs
1311
export class BrowserFileReader implements FileReader {
1412
async openFile(input: UploadInput, chunkSize: number): Promise<FileSource> {
1513
// In React Native, when user selects a file, instead of a File or Blob,
@@ -31,26 +29,11 @@ export class BrowserFileReader implements FileReader {
3129
}
3230
}
3331

34-
// File is a subtype of Blob, so we can check for Blob here.
35-
// TODO: Consider turning Blobs, Buffers, and Uint8Arrays into a single type.
36-
// Potentially handling it in the same way as in Node.js
37-
if (input instanceof Blob) {
38-
return new BlobFileSource(input)
39-
}
40-
41-
if (isWebStream(input)) {
42-
chunkSize = Number(chunkSize)
43-
if (!Number.isFinite(chunkSize)) {
44-
throw new Error(
45-
'cannot create source for stream without a finite value for the `chunkSize` option',
46-
)
47-
}
48-
49-
return new StreamFileSource(input)
50-
}
32+
const fileSource = openBaseFile(input, chunkSize)
33+
if (fileSource) return fileSource
5134

5235
throw new Error(
53-
'source object may only be an instance of File, Blob, or Reader in this environment',
36+
`in this environment the source object may only be an instance of: ${supportedBaseTypes.join(', ')}`,
5437
)
5538
}
5639
}

lib/commonFileReader.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { FileSource, UploadInput } from './options.js'
2+
import { ArrayBufferViewFileSource } from './sources/ArrayBufferViewFileSource.js'
3+
import { BlobFileSource } from './sources/BlobFileSource.js'
4+
import { WebStreamFileSource, isWebStream } from './sources/WebStreamFileSource.js'
5+
6+
/**
7+
* openFile provides FileSources for input types that have to be handled in all environments,
8+
* including Node.js and browsers.
9+
*/
10+
export function openFile(input: UploadInput, chunkSize: number): FileSource | null {
11+
// File is a subtype of Blob, so we only check for Blob here.
12+
if (input instanceof Blob) {
13+
return new BlobFileSource(input)
14+
}
15+
16+
// ArrayBufferViews can be TypedArray (e.g. Uint8Array) or DataView instances.
17+
// Note that Node.js' Buffers are also Uint8Arrays.
18+
if (ArrayBuffer.isView(input)) {
19+
return new ArrayBufferViewFileSource(input)
20+
}
21+
22+
if (input instanceof ArrayBuffer || input instanceof SharedArrayBuffer) {
23+
const view = new DataView(input)
24+
return new ArrayBufferViewFileSource(view)
25+
}
26+
27+
if (isWebStream(input)) {
28+
chunkSize = Number(chunkSize)
29+
if (!Number.isFinite(chunkSize)) {
30+
throw new Error(
31+
'cannot create source for stream without a finite value for the `chunkSize` option',
32+
)
33+
}
34+
35+
return new WebStreamFileSource(input)
36+
}
37+
38+
return null
39+
}
40+
41+
export const supportedTypes = [
42+
'File',
43+
'Blob',
44+
'ArrayBuffer',
45+
'SharedArrayBuffer',
46+
'ArrayBufferView',
47+
'ReadableStreamDefaultReader (Web Streams)',
48+
]

lib/node/NodeFileReader.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,35 @@
11
import { ReadStream } from 'node:fs'
22
import isStream from 'is-stream'
33

4+
import {
5+
openFile as openBaseFile,
6+
supportedTypes as supportedBaseTypes,
7+
} from '../commonFileReader.js'
48
import type { FileReader, UploadInput } from '../options.js'
5-
import { BufferFileSource } from './sources/BufferFileSource.js'
69
import { getFileSource } from './sources/NodeFileSource.js'
7-
import { StreamFileSource } from './sources/StreamFileSource.js'
10+
import { NodeStreamFileSource } from './sources/NodeStreamFileSource.js'
811

912
export class NodeFileReader implements FileReader {
10-
// TODO: Use async here and less Promise.resolve
1113
openFile(input: UploadInput, chunkSize: number) {
12-
if (Buffer.isBuffer(input)) {
13-
return Promise.resolve(new BufferFileSource(input))
14-
}
15-
1614
if (input instanceof ReadStream && input.path != null) {
1715
return getFileSource(input)
1816
}
1917

2018
if (isStream.readable(input)) {
2119
chunkSize = Number(chunkSize)
2220
if (!Number.isFinite(chunkSize)) {
23-
return Promise.reject(
24-
new Error(
25-
'cannot create source for stream without a finite value for the `chunkSize` option; specify a chunkSize to control the memory consumption',
26-
),
21+
throw new Error(
22+
'cannot create source for stream without a finite value for the `chunkSize` option; specify a chunkSize to control the memory consumption',
2723
)
2824
}
29-
return Promise.resolve(new StreamFileSource(input))
25+
return Promise.resolve(new NodeStreamFileSource(input))
3026
}
3127

32-
return Promise.reject(
33-
new Error('source object may only be an instance of Buffer or Readable in this environment'),
28+
const fileSource = openBaseFile(input, chunkSize)
29+
if (fileSource) return Promise.resolve(fileSource)
30+
31+
throw new Error(
32+
`in this environment the source object may only be an instance of: ${supportedBaseTypes.join(', ')}, fs.ReadStream (Node.js), stream.Readable (Node.js)`,
3433
)
3534
}
3635
}

lib/node/NodeHttpStack.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ class Request implements HttpRequest {
102102
reject(err)
103103
})
104104

105+
if (body instanceof ArrayBuffer || body instanceof SharedArrayBuffer) {
106+
body = new Uint8Array(body)
107+
}
108+
109+
if (ArrayBuffer.isView(body) && !(body instanceof Uint8Array)) {
110+
body = new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
111+
}
112+
105113
if (body instanceof Readable) {
106114
// Readable stream are piped through a PassThrough instance, which
107115
// counts the number of bytes passed through. This is used, for example,

lib/node/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ const defaultOptions = {
1919
fingerprint,
2020
}
2121

22-
export type FileTypes = Buffer | Readable | ReadStream
23-
export type FileSliceTypes = Buffer | ReadStream
22+
export type FileTypes = ArrayBuffer | SharedArrayBuffer | ArrayBufferView | Readable | ReadStream
23+
export type FileSliceTypes = ArrayBuffer | SharedArrayBuffer | ArrayBufferView | ReadStream
2424

2525
class Upload extends BaseUpload {
2626
constructor(file: UploadInput, options: Partial<UploadOptions> = {}) {

lib/node/sources/BufferFileSource.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

lib/node/sources/StreamFileSource.ts renamed to lib/node/sources/NodeStreamFileSource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async function readChunk(stream: Readable, size: number) {
4646
*/
4747
// TODO: Consider converting the node stream in a web stream. Then we can share the stream
4848
// handling between browsers and node.js.
49-
export class StreamFileSource implements FileSource {
49+
export class NodeStreamFileSource implements FileSource {
5050
// Setting the size to null indicates that we have no calculation available
5151
// for how much data this stream will emit requiring the user to specify
5252
// it manually (see the `uploadSize` option).

lib/options.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ export interface ReactNativeFile {
2020

2121
export type UploadInput =
2222
// Blob, File, ReadableStreamDefaultReader are available in browsers and Node.js
23-
| Blob
24-
| File
23+
| Blob // includes File
24+
| ArrayBuffer
25+
| SharedArrayBuffer
26+
| ArrayBufferView // includes Node.js' Buffer
2527
// TODO: Should we keep the Pick<> here?
28+
// TODO: Should we also accept ReadableStreamBYOBReader? What about ReadableStream?
2629
| Pick<ReadableStreamDefaultReader, 'read'>
2730
// Buffer, stream.Readable, fs.ReadStream are available in Node.js
28-
| Buffer
29-
| Readable
30-
| ReadStream
31+
| Readable // TODO: Replace this with our own interface based on https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3634b01d50c10ce1afaae63e41d39e7da309d8e3/types/node/globals.d.ts#L399
32+
| ReadStream // TODO: Replace this with { path: string, start: number?, end: number? }
3133
// ReactNativeFile is intended for React Native apps
3234
| ReactNativeFile
3335

@@ -111,8 +113,7 @@ export type SliceResult =
111113
| {
112114
done: boolean
113115
// Platform-specific data type which must be usable by the HTTP stack as a body.
114-
// TODO: Consider making this a Uint8Array (and optionally a web stream).
115-
// This would make typing easier, but then we force file data to be read into memory.
116+
// TODO: This shouldn't be unknown, but precise values.
116117
value: NonNullable<unknown>
117118
size: number
118119
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { FileSource, SliceResult } from '../options.js'
2+
3+
/**
4+
* ArrayBufferViewFileSource implements FileSource for ArrayBufferView instances
5+
* (e.g. TypedArry or DataView).
6+
*
7+
* Note that the underlying ArrayBuffer should not change once passed to tus-js-client
8+
* or it will lead to weird behavior.
9+
*/
10+
export class ArrayBufferViewFileSource implements FileSource {
11+
private _view: ArrayBufferView
12+
13+
size: number
14+
15+
constructor(view: ArrayBufferView) {
16+
this._view = view
17+
this.size = view.byteLength
18+
}
19+
20+
slice(start: number, end: number): Promise<SliceResult> {
21+
const buffer = this._view.buffer
22+
const startInBuffer = this._view.byteOffset + start
23+
end = Math.min(end, this.size) // ensure end is finite and not greater than size
24+
const byteLength = end - start
25+
26+
// Use DataView instead of ArrayBuffer.slice to avoid copying the buffer.
27+
const value = new DataView(buffer, startInBuffer, byteLength)
28+
const size = value.byteLength
29+
const done = end >= this.size
30+
31+
return Promise.resolve({ value, size, done })
32+
}
33+
34+
close() {
35+
// Nothing to do here since we don't need to release any resources.
36+
}
37+
}

lib/browser/sources/BlobFileSource.ts renamed to lib/sources/BlobFileSource.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { isCordova } from '../../cordova/isCordova.js'
2-
import { readAsByteArray } from '../../cordova/readAsByteArray.js'
3-
import type { FileSource, SliceResult } from '../../options.js'
1+
import { isCordova } from '../cordova/isCordova.js'
2+
import { readAsByteArray } from '../cordova/readAsByteArray.js'
3+
import type { FileSource, SliceResult } from '../options.js'
44

5+
/**
6+
* BlobFileSource implements FileSource for Blobs (and therefore also for File instances).
7+
*/
58
export class BlobFileSource implements FileSource {
69
private _file: Blob
710

@@ -13,6 +16,8 @@ export class BlobFileSource implements FileSource {
1316
}
1417

1518
async slice(start: number, end: number): Promise<SliceResult> {
19+
// TODO: This looks fishy. We should test how this actually works in Cordova
20+
// and consider moving this into the lib/cordova/ directory.
1621
// In Apache Cordova applications, a File must be resolved using
1722
// FileReader instances, see
1823
// https://cordova.apache.org/docs/en/8.x/reference/cordova-plugin-file/index.html#read-a-file

lib/browser/sources/StreamFileSource.ts renamed to lib/sources/WebStreamFileSource.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { FileSource, SliceResult } from '../../options.js'
1+
import type { FileSource, SliceResult, UploadInput } from '../options.js'
22

3-
function len(blobOrArray: StreamFileSource['_buffer']): number {
3+
function len(blobOrArray: WebStreamFileSource['_buffer']): number {
44
if (blobOrArray === undefined) return 0
55
if (blobOrArray instanceof Blob) return blobOrArray.size
66
return blobOrArray.length
@@ -10,7 +10,7 @@ function len(blobOrArray: StreamFileSource['_buffer']): number {
1010
Typed arrays and blobs don't have a concat method.
1111
This function helps StreamSource accumulate data to reach chunkSize.
1212
*/
13-
function concat<T extends StreamFileSource['_buffer']>(a: T, b: T): T {
13+
function concat<T extends WebStreamFileSource['_buffer']>(a: T, b: T): T {
1414
if (a instanceof Blob && b instanceof Blob) {
1515
return new Blob([a, b], { type: a.type }) as T
1616
}
@@ -23,8 +23,11 @@ function concat<T extends StreamFileSource['_buffer']>(a: T, b: T): T {
2323
throw new Error('Unknown data type')
2424
}
2525

26-
export class StreamFileSource implements FileSource {
27-
private _reader: Pick<ReadableStreamDefaultReader<StreamFileSource['_buffer']>, 'read'>
26+
/**
27+
* WebStreamFileSource implements FileSource for Web Streams.
28+
*/
29+
export class WebStreamFileSource implements FileSource {
30+
private _reader: Pick<ReadableStreamDefaultReader<WebStreamFileSource['_buffer']>, 'read'>
2831

2932
private _buffer: Blob | Uint8Array | undefined
3033

@@ -104,3 +107,9 @@ export class StreamFileSource implements FileSource {
104107
if (this._reader.cancel) this._reader.cancel()
105108
}
106109
}
110+
111+
export function isWebStream(
112+
input: UploadInput,
113+
): input is Pick<ReadableStreamDefaultReader, 'read'> {
114+
return 'read' in input && typeof input.read === 'function'
115+
}

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,14 @@
3030
}
3131
}
3232
},
33-
"./node/sources/StreamFileSource": {
33+
"./node/sources/NodeStreamFileSource": {
3434
"import": {
35-
"types": "./lib.esm/node/sources/StreamFileSource.d.ts",
36-
"default": "./lib.esm/node/sources/StreamFileSource.js"
35+
"types": "./lib.esm/node/sources/NodeStreamFileSource.d.ts",
36+
"default": "./lib.esm/node/sources/NodeStreamFileSource.js"
3737
},
3838
"require": {
39-
"types": "./lib.cjs/node/sources/StreamFileSource.d.ts",
40-
"default": "./lib.cjs/node/sources/StreamFileSource.js"
39+
"types": "./lib.cjs/node/sources/NodeStreamFileSource.d.ts",
40+
"default": "./lib.cjs/node/sources/NodeStreamFileSource.js"
4141
}
4242
},
4343
"./node/FileUrlStorage": {

test/spec/helpers/utils.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,15 @@ async function getBodySize(body) {
153153
return null
154154
}
155155

156-
if (body.size != null) {
156+
if (
157+
body instanceof ArrayBuffer ||
158+
body instanceof SharedArrayBuffer ||
159+
ArrayBuffer.isView(body)
160+
) {
161+
return body.byteLength
162+
}
163+
164+
if (body instanceof Blob) {
157165
return body.size
158166
}
159167

0 commit comments

Comments
 (0)