Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions lib/browser/BrowserFileReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
supportedTypes as supportedBaseTypes,
} from '../commonFileReader.js'
import type { FileReader, FileSource, UploadInput } from '../options.js'
import { BlobFileSource } from '../sources/BlobFileSource.js'
import { ReactNativeFileSource } from "../sources/ReactNativeFileSource.js";

export class BrowserFileReader implements FileReader {
async openFile(input: UploadInput, chunkSize: number): Promise<FileSource> {
Expand All @@ -20,8 +20,7 @@ export class BrowserFileReader implements FileReader {
}

try {
const blob = await uriToBlob(input.uri)
return new BlobFileSource(blob)
return new ReactNativeFileSource(input)
} catch (err) {
throw new Error(
`tus: cannot fetch \`file.uri\` as Blob, make sure the uri is correct and accessible. ${err}`,
Expand Down
25 changes: 0 additions & 25 deletions lib/browser/fileSignature.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import type { ReactNativeFile, UploadInput, UploadOptions } from '../options.js'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire file can probably be removed now, can't it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file has been removed.

import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js'

/**
* Generate a fingerprint for a file which will be used the store the endpoint
*/
export function fingerprint(file: UploadInput, options: UploadOptions) {
if (isReactNativePlatform() && isReactNativeFile(file)) {
return Promise.resolve(reactNativeFingerprint(file, options))
}

if (file instanceof Blob) {
return Promise.resolve(
Expand All @@ -19,24 +15,3 @@ export function fingerprint(file: UploadInput, options: UploadOptions) {

return Promise.resolve(null)
}

function reactNativeFingerprint(file: ReactNativeFile, options: UploadOptions): string {
const exifHash = file.exif ? hashCode(JSON.stringify(file.exif)) : 'noexif'
return ['tus-rn', file.name || 'noname', file.size || 'nosize', exifHash, options.endpoint].join(
'/',
)
}

function hashCode(str: string): number {
// from https://stackoverflow.com/a/8831937/151666
let hash = 0
if (str.length === 0) {
return hash
}
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash &= hash // Convert to 32bit integer
}
return hash
}
13 changes: 10 additions & 3 deletions lib/node/sources/NodeStreamFileSource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Readable } from 'node:stream'
import type { FileSource } from '../../options.js'
import { Readable, Transform } from 'node:stream'
import type { FileSource, UploadOptions } from '../../options.js'

/**
* readChunk reads a chunk with the given size from the given
Expand Down Expand Up @@ -74,9 +74,16 @@ export class NodeStreamFileSource implements FileSource {
})
}

// TODO: Implement fingerprint calculation for NodeStreamFileSource.
// This should likely involve reading the stream and creating a hash/checksum
// while preserving the stream's content for later use.
fingerprint(options: UploadOptions): Promise<string | null> {
throw new Error("fingerprint not implemented for NodeStreamFileSource")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, it's best to return null to keep the existing behavior and align with WebStreamFileSource.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've modified the method to return a promise that resolves to null.

}

async slice(start: number, end: number) {
// Fail fast if the caller requests a proportion of the data which is not
// available any more.
// available anymore.
if (start < this._bufPos) {
throw new Error('cannot slice from position which we already seeked away')
}
Expand Down
12 changes: 10 additions & 2 deletions lib/node/sources/PathFileSource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ReadStream, createReadStream, promises as fsPromises } from 'node:fs'
import type { FileSource, PathReference } from '../../options.js'
import { createReadStream, promises as fsPromises, type ReadStream } from 'node:fs'
import type { FileSource, PathReference, UploadOptions } from '../../options.js'

export async function getFileSourceFromPath(file: PathReference): Promise<PathFileSource> {
const path = file.path.toString()
Expand Down Expand Up @@ -34,6 +34,14 @@ export class PathFileSource implements FileSource {
this.size = size
}

fingerprint(options: UploadOptions): Promise<string | null> {
if (typeof options.fingerprint === 'function') {
return Promise.resolve(options.fingerprint(this._file, options))
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing users to modify the fingerprint is an important aspect to ensure that tus-js-client is customizable. My vision for the current fingerprint option is that its a signature like this:

fingerprint: (file: UploadInput, options: UploadOptions, sourceFingerprint: string | null) => Promise<string | null>

The callback gets the fingerprint from the file source passed and can then decide to modify it (e.g. by appending information) or to overwrite it completely.

Invoking this callback is best handled in the Upload class, so it doesn't have to be done in every implementation of FileSource. Does this make sense?

Copy link
Author

@major-winter major-winter May 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me summarize how I understand the direction:

  • We’ll extend the fingerprint option signature to include a sourceFingerprint parameter. Just to confirm: is this sourceFingerprint the value returned by the fingerprint method of the specific FileSource being used?

  • The fingerprint callback can then decide whether to use the sourceFingerprint as-is or modify it (e.g., by appending a project ID). Could you clarify what criteria should guide this decision?

  • This callback will be implemented centrally in the BaseUpload class instead of the Upload, so that both the Node and Browser environments can share the logic — and individual FileSource classes won’t need to handle user-provided fingerprint logic themselves.

  • From what I understand, the fingerprint option exists to let users override the default behavior and provide their own implementation. Since each FileSource can still generate a fingerprint based on its specific type, I believe the fingerprint option in the UploadOptions interface should be made optional.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • We’ll extend the fingerprint option signature to include a sourceFingerprint parameter. Just to confirm: is this sourceFingerprint the value returned by the fingerprint method of the specific FileSource being used?

Yes, that's correct. sourceFingerprint can also be null if FileSource didn't supply one, e.g. for streams, allowing the user to still supply a fingerprint if they desire to do so.

  • The fingerprint callback can then decide whether to use the sourceFingerprint as-is or modify it (e.g., by appending a project ID). Could you clarify what criteria should guide this decision?

As you mentioned, they might want to scope uploads per project and thus embed information identifying the project in the fingerprint.

  • From what I understand, the fingerprint option exists to let users override the default behavior and provide their own implementation. Since each FileSource can still generate a fingerprint based on its specific type, I believe the fingerprint option in the UploadOptions interface should be made optional.

fingerprint in UploadOptions shouldn't be made optional. UploadOptions represent the type after default values are filled, where fingerprint is always set (if I remember correctly). That being said, the Upload constructor accepts Partial<UploadOptions>, so fingerprint is already optional from the user's perspective.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification! Please take a look at the updated changes.


return Promise.resolve([this._file, this.size, this._path, options.endpoint, options.projectId].join('-'))
}

slice(start: number, end: number) {
// TODO: Does this create multiple file descriptors? Can we reduce this by
// using a file handle instead?
Expand Down
5 changes: 4 additions & 1 deletion lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface ReactNativeFile {
/**
* PathReference is a reference to a file on disk. Currently, it's only supported
* in Node.js. It can be supplied as a normal object or as an instance of `fs.ReadStream`,
* which also statisfies this interface.
* which also satisfies this interface.
*
* Optionally, a start and/or end position can be defined to define a range of bytes from
* the file that should be uploaded instead of the entire file. Both start and end are
Expand Down Expand Up @@ -51,6 +51,8 @@ export type UploadInput =
export interface UploadOptions {
endpoint?: string

projectId?: string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain what this option is about?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intend to use it to distinguish the same file across multiple projects. However, this property is better suited as part of the metadata rather than being placed here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think such a specific option is necessary, as users can just append a project ID (or any other data) using the fingerprint callback to modify the fingerprint from the file source.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field has been removed.


uploadUrl?: string
metadata: { [key: string]: string }
metadataForPartialUploads: UploadOptions['metadata']
Expand Down Expand Up @@ -117,6 +119,7 @@ export interface FileSource {
size: number | null
slice(start: number, end: number): Promise<SliceResult>
close(): void
fingerprint(options: UploadOptions): Promise<string | null>
}

// TODO: Allow Web Streams' ReadableStream as well
Expand Down
10 changes: 8 additions & 2 deletions lib/sources/ArrayBufferViewFileSource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FileSource, SliceResult } from '../options.js'
import type { FileSource, SliceResult, UploadOptions } from '../options.js'

/**
* ArrayBufferViewFileSource implements FileSource for ArrayBufferView instances
Expand All @@ -8,7 +8,7 @@ import type { FileSource, SliceResult } from '../options.js'
* or it will lead to weird behavior.
*/
export class ArrayBufferViewFileSource implements FileSource {
private _view: ArrayBufferView
private readonly _view: ArrayBufferView

size: number

Expand All @@ -17,6 +17,12 @@ export class ArrayBufferViewFileSource implements FileSource {
this.size = view.byteLength
}

// TODO: Consider implementing a fingerprint function that uses a checksum/hash of the ArrayBufferView data
// TODO: Could also include byteOffset, byteLength and buffer.byteLength in fingerprint calculation
fingerprint(options: UploadOptions): Promise<string | null> {
return Promise.resolve(null);
}

slice(start: number, end: number): Promise<SliceResult> {
const buffer = this._view.buffer
const startInBuffer = this._view.byteOffset + start
Expand Down
22 changes: 20 additions & 2 deletions lib/sources/BlobFileSource.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { isCordova } from '../cordova/isCordova.js'
import { readAsByteArray } from '../cordova/readAsByteArray.js'
import type { FileSource, SliceResult } from '../options.js'
import type { FileSource, SliceResult, UploadOptions } from '../options.js'

/**
* BlobFileSource implements FileSource for Blobs (and therefore also for File instances).
*/
export class BlobFileSource implements FileSource {
private _file: Blob
private readonly _file: Blob

size: number

Expand All @@ -15,6 +15,24 @@ export class BlobFileSource implements FileSource {
this.size = file.size
}

fingerprint(options: UploadOptions): Promise<string | null> {
if (typeof options.fingerprint === 'function') {
return Promise.resolve(options.fingerprint(this._file, options));
}

let name, lastModified;
const projectId = options.projectId || 'defaultProjectId';
if (this._file instanceof File) {
name = this._file.name;
lastModified = this._file.lastModified;
} else {
name = 'blob';
lastModified = 0;
}

return Promise.resolve(['tus-br', name, this._file.type, this._file.size, lastModified, options.endpoint, projectId].join('-'));
}

async slice(start: number, end: number): Promise<SliceResult> {
// TODO: This looks fishy. We should test how this actually works in Cordova
// and consider moving this into the lib/cordova/ directory.
Expand Down
55 changes: 55 additions & 0 deletions lib/sources/ReactNativeFileSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { FileSource, ReactNativeFile, SliceResult, UploadOptions } from "../options.js";

export class ReactNativeFileSource implements FileSource {
private readonly _file: ReactNativeFile
size: number;

constructor(file: ReactNativeFile) {
this._file = file
this.size = Number(file.size)
}

fingerprint(options: UploadOptions): Promise<string | null> {
if (typeof options.fingerprint === 'function') {
return Promise.resolve(options.fingerprint(this._file, options))
}

return Promise.resolve(this.reactNativeFingerprint(this._file, options))
}

// TODO: Implement the slice method to read file content from start to end positions
// The implementation should:
// 1. Read the file content from the ReactNative file URI
// 2. Return the sliced content as value
// 3. Calculate proper size and done values
async slice(start: number, end: number): Promise<SliceResult> {
let value = null, size = null, done = true;

return { value, size, done }
}

close(): void {
// Nothing to do here since we don't need to release any resources.
}

private reactNativeFingerprint(file: ReactNativeFile, options: UploadOptions): string {
const exifHash = file.exif ? this.hashCode(JSON.stringify(file.exif)) : 'noexif'
return ['tus-rn', file.name || 'noname', file.size || 'nosize', exifHash, options.endpoint].join(
'/',
)
}

private hashCode(str: string): number {
// from https://stackoverflow.com/a/8831937/151666
let hash = 0
if (str.length === 0) {
return hash
}
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash &= hash // Convert to 32bit integer
}
return hash
}
}
12 changes: 9 additions & 3 deletions lib/sources/WebStreamFileSource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FileSource, SliceResult } from '../options.js'
import type {FileSource, SliceResult, UploadOptions} from '../options.js'

function len(blobOrArray: WebStreamFileSource['_buffer']): number {
if (blobOrArray === undefined) return 0
Expand Down Expand Up @@ -28,7 +28,7 @@ function concat<T extends WebStreamFileSource['_buffer']>(a: T, b: T): T {
*/
// TODO: Can we share code with NodeStreamFileSource?
export class WebStreamFileSource implements FileSource {
private _reader: ReadableStreamDefaultReader<Uint8Array>
private readonly _reader: ReadableStreamDefaultReader<Uint8Array>

private _buffer: Blob | Uint8Array | undefined

Expand All @@ -39,6 +39,8 @@ export class WebStreamFileSource implements FileSource {

private _done = false

private _stream: ReadableStream
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property doesn't seem used. Can it be removed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll remove it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this property has been removed.


// Setting the size to null indicates that we have no calculation available
// for how much data this stream will emit requiring the user to specify
// it manually (see the `uploadSize` option).
Expand All @@ -50,10 +52,14 @@ export class WebStreamFileSource implements FileSource {
'Readable stream is already locked to reader. tus-js-client cannot obtain a new reader.',
)
}

this._stream = stream
this._reader = stream.getReader()
}

fingerprint(options: UploadOptions): Promise<string | null> {
return Promise.resolve(null);
}

async slice(start: number, end: number): Promise<SliceResult> {
if (start < this._bufferOffset) {
throw new Error("Requested data is before the reader's current offset")
Expand Down
12 changes: 6 additions & 6 deletions lib/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class BaseUpload {
// The fingerpinrt for the current file (set after start())
private _fingerprint: string | null = null

// The key that the URL storage returned when saving an URL with a fingerprint,
// The key that the URL storage returned when saving a URL with a fingerprint,
private _urlStorageKey?: string

// The offset used in the current PATCH request
Expand Down Expand Up @@ -223,7 +223,10 @@ export class BaseUpload {
}

private async _prepareAndStartUpload(): Promise<void> {
this._fingerprint = await this.options.fingerprint(this.file, this.options)
if (this._source == null) {
this._source = await this.options.fileReader.openFile(this.file, this.options.chunkSize)
}
this._fingerprint = await this._source.fingerprint(this.options);
if (this._fingerprint == null) {
log(
'No fingerprint was calculated meaning that the upload cannot be stored in the URL storage.',
Expand All @@ -232,10 +235,6 @@ export class BaseUpload {
log(`Calculated fingerprint: ${this._fingerprint}`)
}

if (this._source == null) {
this._source = await this.options.fileReader.openFile(this.file, this.options.chunkSize)
}

// First, we look at the uploadLengthDeferred option.
// Next, we check if the caller has supplied a manual upload size.
// Finally, we try to use the calculated size from the source object.
Expand Down Expand Up @@ -1218,5 +1217,6 @@ export async function terminate(url: string, options: UploadOptions): Promise<vo

await wait(delay)
await terminate(url, newOptions)

}
}
Loading