Skip to content

Commit 01bd299

Browse files
authored
Replace fs.ReadStream-centric handling with more generic PathReference (#767)
1 parent c912232 commit 01bd299

File tree

6 files changed

+89
-47
lines changed

6 files changed

+89
-47
lines changed

demos/nodejs/index.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { createReadStream } from 'node:fs'
21
import { Upload } from 'tus-js-client'
32

43
const path = `${import.meta.dirname}/../../README.md`
5-
const file = createReadStream(path)
4+
const file = { path }
65

76
const options = {
87
endpoint: 'https://tusd.tusdemo.net/files/',

lib/node/NodeFileReader.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
1-
import { ReadStream } from 'node:fs'
1+
import { createReadStream } from 'node:fs'
22
import isStream from 'is-stream'
33

44
import {
55
openFile as openBaseFile,
66
supportedTypes as supportedBaseTypes,
77
} from '../commonFileReader.js'
8-
import type { FileReader, UploadInput } from '../options.js'
9-
import { getFileSource } from './sources/NodeFileSource.js'
8+
import type { FileReader, PathReference, UploadInput } from '../options.js'
109
import { NodeStreamFileSource } from './sources/NodeStreamFileSource.js'
10+
import { getFileSourceFromPath } from './sources/PathFileSource.js'
11+
12+
function isPathReference(input: UploadInput): input is PathReference {
13+
return (
14+
typeof input === 'object' &&
15+
input !== null &&
16+
'path' in input &&
17+
(typeof input.path === 'string' || Buffer.isBuffer(input.path))
18+
)
19+
}
1120

1221
export class NodeFileReader implements FileReader {
1322
openFile(input: UploadInput, chunkSize: number) {
14-
if (input instanceof ReadStream && input.path != null) {
15-
return getFileSource(input)
23+
if (isPathReference(input)) {
24+
return getFileSourceFromPath(input)
1625
}
1726

1827
if (isStream.readable(input)) {
@@ -33,3 +42,19 @@ export class NodeFileReader implements FileReader {
3342
)
3443
}
3544
}
45+
46+
/**
47+
* This (unused) function is a simple test to ensure that fs.ReadStreams
48+
* satisfy the PathReference interface. In the past, tus-js-client explicitly
49+
* accepted fs.ReadStreams and included it in its type definitions.
50+
*
51+
* Since tus-js-client v5, we have moved away from only accepting fs.ReadStream
52+
* in favor of a more generic PathReference. This function ensures that the definition
53+
* of PathReference includes fs.ReadStream. If this wasn't the case, the TypeScript
54+
* compiler would complain during the build step, making this a poor-man's type test.
55+
*/
56+
// biome-ignore lint/correctness/noUnusedVariables: see above
57+
function testFsReadStreamAsPathReference() {
58+
const pathReference: PathReference = createReadStream('test.txt')
59+
new NodeFileReader().openFile(pathReference, 1024)
60+
}

lib/node/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Readable } from 'node:stream'
33
import { DetailedError } from '../DetailedError.js'
44
import { NoopUrlStorage } from '../NoopUrlStorage.js'
55
import { enableDebugLog } from '../logger.js'
6-
import type { UploadInput, UploadOptions } from '../options.js'
6+
import type { PathReference, UploadInput, UploadOptions } from '../options.js'
77
import { BaseUpload, defaultOptions as baseDefaultOptions, terminate } from '../upload.js'
88

99
import { canStoreURLs } from './FileUrlStorage.js'
@@ -25,7 +25,8 @@ export type FileTypes =
2525
| ArrayBufferView
2626
| Blob
2727
| Readable
28-
| ReadStream
28+
| PathReference
29+
2930
export type FileSliceTypes = ArrayBuffer | SharedArrayBuffer | ArrayBufferView | Blob | ReadStream
3031

3132
class Upload extends BaseUpload {
Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,47 @@
11
import { type ReadStream, createReadStream, promises as fsPromises } from 'node:fs'
2-
import type { FileSource } from '../../options.js'
2+
import type { FileSource, PathReference } from '../../options.js'
33

4-
export async function getFileSource(stream: ReadStream): Promise<NodeFileSource> {
5-
const path = stream.path.toString()
4+
export async function getFileSourceFromPath(file: PathReference): Promise<PathFileSource> {
5+
const path = file.path.toString()
66
const { size } = await fsPromises.stat(path)
77

8-
// The fs.ReadStream might be configured to not read from the beginning
8+
// The path reference might be configured to not read from the beginning
99
// to the end, but instead from a slice in between. In this case, we consider
1010
// that range to indicate the actual uploadable size.
11-
// This happens, for example, if a fs.ReadStream is used with the `parallelUploads`
12-
// option. There, the ReadStream is sliced into multiple ReadStreams to fit the number
13-
// of number of `parallelUploads`. Each ReadStream has `start` and `end` set.
11+
// This happens, for example, if a path reference is used with the `parallelUploads`
12+
// option. There, the path reference is sliced into multiple fs.ReadStreams to fit the
13+
// number of `parallelUploads`. Each ReadStream has `start` and `end` set.
1414
// Note: `stream.end` is Infinity by default, so we need the check `isFinite`.
1515
// Note: `stream.end` is treated inclusively, so we need to add 1 here.
1616
// See the comment in slice() for more context.
17-
// @ts-expect-error The types don't know start yet.
18-
const start = stream.start ?? 0
19-
// @ts-expect-error The types don't know end yet.
20-
const end = Number.isFinite(stream.end) ? stream.end + 1 : size
17+
const start = file.start ?? 0
18+
const end = file.end != null && Number.isFinite(file.end) ? file.end + 1 : size
2119
const actualSize = end - start
2220

23-
return new NodeFileSource(stream, path, actualSize)
21+
return new PathFileSource(file, path, actualSize)
2422
}
2523

26-
export class NodeFileSource implements FileSource {
24+
export class PathFileSource implements FileSource {
2725
size: number
2826

29-
private _stream: ReadStream
27+
private _file: PathReference
3028

3129
private _path: string
3230

33-
constructor(stream: ReadStream, path: string, size: number) {
34-
this._stream = stream
31+
constructor(file: PathReference, path: string, size: number) {
32+
this._file = file
3533
this._path = path
3634
this.size = size
3735
}
3836

3937
slice(start: number, end: number) {
40-
// The fs.ReadStream might be configured to not read from the beginning,
38+
// The path reference might be configured to not read from the beginning,
4139
// but instead start at a different offset. The start value from the caller
4240
// does not include the offset, so we need to add this offset to our range later.
43-
// This happens, for example, if a fs.ReadStream is used with the `parallelUploads`
44-
// option. First, the ReadStream is sliced into multiple ReadStreams to fit the number
45-
// of number of `parallelUploads`. Each ReadStream has `start` set.
46-
// @ts-expect-error The types don't know start yet.
47-
const offset = this._stream.start ?? 0
41+
// This happens, for example, if a path reference is used with the `parallelUploads`
42+
// option. First, the path reference is sliced into multiple fs.ReadStreams to fit the
43+
// number of `parallelUploads`. Each ReadStream has `start` set.
44+
const offset = this._file.start ?? 0
4845

4946
const stream: ReadStream & { size?: number } = createReadStream(this._path, {
5047
start: offset + start,
@@ -62,6 +59,7 @@ export class NodeFileSource implements FileSource {
6259
}
6360

6461
close() {
65-
this._stream.destroy()
62+
// TODO: Ensure that the read streams are closed
63+
// TODO: Previously, the passed fs.ReadStream was closed here. Should we keep this behavior? If not, this is a breaking change.
6664
}
6765
}

lib/options.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,40 @@
1-
import type { ReadStream } from 'node:fs'
21
import type { Readable } from 'node:stream'
32
import type { DetailedError } from './DetailedError.js'
43

54
export const PROTOCOL_TUS_V1 = 'tus-v1'
65
export const PROTOCOL_IETF_DRAFT_03 = 'ietf-draft-03'
76
export const PROTOCOL_IETF_DRAFT_05 = 'ietf-draft-05'
87

9-
// ReactNativeFile describes the structure that is returned from the
10-
// Expo image picker (see https://docs.expo.dev/versions/latest/sdk/imagepicker/)
11-
// TODO: Should these properties be fileName and fileSize instead?
12-
// TODO: What about other file pickers without Expo?
13-
// TODO: Should this be renamed to Expo?
8+
/**
9+
* ReactNativeFile describes the structure that is returned from the
10+
* Expo image picker (see https://docs.expo.dev/versions/latest/sdk/imagepicker/)
11+
* TODO: Should these properties be fileName and fileSize instead?
12+
* TODO: What about other file pickers without Expo?
13+
* TODO: Should this be renamed to Expo?
14+
* TODO: Only size is relevant for us. Not the rest.
15+
*/
1416
export interface ReactNativeFile {
1517
uri: string
1618
name?: string
1719
size?: string
1820
exif?: Record<string, unknown>
1921
}
2022

23+
/**
24+
* PathReference is a reference to a file on disk. Currently, it's only supported
25+
* in Node.js. It can be supplied as a normal object or as an instance of `fs.ReadStream`,
26+
* which also statisfies this interface.
27+
*
28+
* Optionally, a start and/or end position can be defined to define a range of bytes from
29+
* the file that should be uploaded instead of the entire file. Both start and end are
30+
* inclusive and start counting at 0, similar to the options accepted by `fs.createReadStream`.
31+
*/
32+
export interface PathReference {
33+
path: string | Buffer
34+
start?: number
35+
end?: number
36+
}
37+
2138
export type UploadInput =
2239
// Blob, File, ReadableStreamDefaultReader are available in browsers and Node.js
2340
| Blob // includes File
@@ -29,8 +46,7 @@ export type UploadInput =
2946
| Pick<ReadableStreamDefaultReader, 'read'>
3047
// Buffer, stream.Readable, fs.ReadStream are available in Node.js
3148
| 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? }
33-
// ReactNativeFile is intended for React Native apps
49+
| PathReference
3450
| ReactNativeFile
3551

3652
export interface UploadOptions {

test/spec/test-node-specific.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from 'node:fs'
44
import http from 'node:http'
55
import https from 'node:https'
66
import stream from 'node:stream'
7+
import { text } from 'node:stream/consumers'
78
import intoStream from 'into-stream'
89
import temp from 'temp'
910
import { Upload, canStoreURLs } from 'tus-js-client'
@@ -32,7 +33,7 @@ describe('tus', () => {
3233
await expectHelloWorldUpload(buffer, options)
3334
})
3435

35-
describe('uploading from a stream', () => {
36+
describe('uploading from a Node.js stream', () => {
3637
it('should reject streams without specifying the size', async () => {
3738
const input = new stream.PassThrough()
3839
const options = {
@@ -141,28 +142,30 @@ describe('tus', () => {
141142
})
142143
})
143144

144-
describe('uploading from a fs.ReadStream', () => {
145+
describe('uploading from a file on disk', () => {
145146
it('should accept fs.ReadStream', async () => {
146147
// Create a temporary file
147148
const path = temp.path()
148-
fs.writeFileSync(path, 'hello world')
149-
const file = fs.createReadStream(path)
149+
fs.writeFileSync(path, 'XXXhello worldXXX')
150+
const file1 = fs.createReadStream(path, { start: 3, end: 13 })
151+
expect(await text(file1)).toBe('hello world') // ensure that tus-js-client uploads the same data as fs.createReadStream
152+
const file2 = fs.createReadStream(path, { start: 3, end: 13 })
150153

151154
const options = {
152155
httpStack: new TestHttpStack(),
153156
endpoint: '/uploads',
154157
chunkSize: 7,
155158
}
156159

157-
await expectHelloWorldUpload(file, options)
160+
await expectHelloWorldUpload(file2, options)
158161
})
159162

160163
it('should support parallelUploads', async () => {
161164
// TODO: The ordering of requests is no longer deterministic, so we need to update this test
162165
// Create a temporary file
163166
const path = temp.path()
164167
fs.writeFileSync(path, 'hello world')
165-
const file = fs.createReadStream(path)
168+
const file = { path }
166169

167170
const testStack = new TestHttpStack()
168171

@@ -263,7 +266,7 @@ describe('tus', () => {
263266
// Create a temporary file
264267
const path = temp.path()
265268
fs.writeFileSync(path, 'hello world')
266-
const file = fs.createReadStream(path)
269+
const file = { path }
267270

268271
const testStack = new TestHttpStack()
269272
const options = {

0 commit comments

Comments
 (0)