Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
124 commits
Select commit Hold shift + click to select a range
89ed49a
Mark protocol versions as generated
kvz May 26, 2026
6358760
Use generated protocol contract in upload test
kvz May 26, 2026
06d5692
Format generated protocol contract test
kvz May 26, 2026
8078cea
Expose generated TUS wire version
kvz May 26, 2026
9d89ce8
Add generated TUS conformance scenarios
kvz May 26, 2026
6130b34
Stabilize parallel upload test ordering
kvz May 26, 2026
be54479
Relax parallel upload progress ordering
kvz May 26, 2026
851ed0c
Use generated TUS protocol facts
kvz May 26, 2026
14830ec
Use generated TUS client protocol constants
kvz May 26, 2026
11e0c64
Use generated TUS operation methods
kvz May 26, 2026
07bff4f
Use generated TUS protocol helpers
kvz May 26, 2026
2dbc304
Use generated TUS protocol plans
kvz May 26, 2026
2404628
Drive more TUS scenarios from generated contract
kvz May 26, 2026
9130f3a
Use generated TUS client flow helpers
kvz May 26, 2026
fca4481
Mark TUS runtime files generated
kvz May 26, 2026
e5c62ce
Mark remaining source files generated
kvz May 26, 2026
a5debf9
Keep generated boundary to protocol helpers
kvz May 26, 2026
66f66a8
Regenerate TUS feature contract fixture
kvz May 26, 2026
603f7a0
Install Puppeteer browser in CI
kvz May 26, 2026
1371d90
Regenerate upload body protocol fixtures
kvz May 27, 2026
bd749ac
Use generated TUS chunk response plan
kvz May 27, 2026
e03379a
Use generated TUS creation response plan
kvz May 27, 2026
ca964b2
Use generated TUS resume response plans
kvz May 27, 2026
4f06e97
Use generated TUS retry planning
kvz May 27, 2026
379211f
Use generated TUS upload size planning
kvz May 27, 2026
1be5e16
Use generated TUS upload mode planning
kvz May 27, 2026
9ebd11d
Clear Puppeteer browser cache in CI
kvz May 27, 2026
0b7bd29
Use generated TUS parallel upload part planning
kvz May 28, 2026
c6ee125
Install pinned Puppeteer Chrome in CI
kvz May 28, 2026
b35ccc0
Prefer system Chrome for Puppeteer CI
kvz May 28, 2026
ed5d398
Use generated TUS partial upload options
kvz May 28, 2026
baa895c
Use generated TUS final upload plan
kvz May 28, 2026
4b288c9
Use generated TUS upload creation plan
kvz May 28, 2026
d6049b0
Use generated TUS resume request plan
kvz May 28, 2026
1383b7d
Use generated TUS chunk request plan
kvz May 28, 2026
bfd5d5e
Use generated TUS protocol constants
kvz May 28, 2026
d90386c
Use generated TUS terminate request plan
kvz May 28, 2026
ec82aa9
Use generated TUS URL storage keys
kvz May 28, 2026
1841093
Use generated TUS flow failure planners
kvz May 28, 2026
e0b27eb
Use generated plan for removed resume warning
kvz May 28, 2026
ed451ee
Use generated TUS runtime log plans
kvz May 28, 2026
1d17ffd
Use generated request id header in errors
kvz May 28, 2026
af95ab0
Use generated message for non-error throws
kvz May 28, 2026
ce43943
Use generated React Native file reader messages
kvz May 28, 2026
f4345a0
Increase Puppeteer Karma activity timeout
kvz May 28, 2026
3389be3
Use generated TUS file source policy
kvz May 28, 2026
dc70aa9
Use generated TUS client defaults
kvz May 28, 2026
5ac273b
Use generated TUS request header policy
kvz May 28, 2026
ae22b69
Use generated TUS Location resolution
kvz May 28, 2026
d5508b5
Use generated TUS request lifecycle policy
kvz May 28, 2026
7ed2b65
Use generated TUS upload URL hook policy
kvz May 28, 2026
4daad50
Use generated TUS URL storage record policy
kvz May 28, 2026
f4758c6
Use generated TUS cleanup policy
kvz May 28, 2026
22b2c25
Use generated TUS abort sequence
kvz May 28, 2026
2e5a488
Use generated TUS HTTP stack policy
kvz May 28, 2026
af2129a
Use generated TUS URL storage policy
kvz May 28, 2026
ed31119
Use generated TUS abort error descriptor
kvz May 28, 2026
0463d54
Use generated TUS fingerprint policy
kvz May 28, 2026
fcb3106
Use generated TUS progress throttle policy
kvz May 28, 2026
9890455
Use generated TUS detailed error formatting
kvz May 28, 2026
2bb12d1
Use generated TUS Cordova file source error
kvz May 28, 2026
a446130
Use generated TUS retry online policy
kvz May 28, 2026
681a81e
Use generated TUS metadata encoding
kvz May 28, 2026
158663e
Use generated TUS Location resolution
kvz May 28, 2026
4929c29
Use generated TUS URL storage timestamps
kvz May 28, 2026
6cef818
Use generated TUS request ID planning
kvz May 28, 2026
5d46da2
Use generated TUS retryability policy
kvz May 28, 2026
5325d89
Use generated TUS abort error suppression
kvz May 28, 2026
950e3b5
Use generated TUS upload event planning
kvz May 28, 2026
01f36f6
Assert generated TUS upload events
kvz May 28, 2026
de01c0a
Cover TUS request lifecycle conformance
kvz May 28, 2026
8276945
Cover TUS abort conformance
kvz May 29, 2026
6651c61
Cover TUS URL storage conformance
kvz May 29, 2026
585c0ff
Cover TUS relative Location conformance
kvz May 29, 2026
c7b5c8d
Cover TUS input source conformance
kvz May 29, 2026
e0dc98c
Cover TUS retry state conformance
kvz May 29, 2026
cebe2dc
Cover TUS URL storage backends
kvz May 29, 2026
977ae97
Cover protocol selection conformance
kvz May 29, 2026
e22bb0c
Cover start validation conformance
kvz May 29, 2026
a6d0457
Cover detailed error conformance
kvz May 29, 2026
5779fd4
Converge generated TUS conformance
kvz May 31, 2026
3df1f2e
Tighten generated runtime event keys
kvz May 31, 2026
d50a576
Use generated TUS event policy
kvz May 31, 2026
ee7362e
Tighten generated TUS event matching
kvz Jun 1, 2026
b88fdec
Update generated TUS retry events
kvz Jun 1, 2026
14ec595
Use generated TUS proof profiles
kvz Jun 1, 2026
e106fd9
Use generated TUS execution hints
kvz Jun 1, 2026
34d81eb
Expose TUS request-start cancellation hints
kvz Jun 1, 2026
6e5cacb
Expose TUS parallel request gates
kvz Jun 1, 2026
836776a
Expose TUS managed upload contract
kvz Jun 1, 2026
73b7149
Expose managed upload proof cases
kvz Jun 1, 2026
d93bd0f
Update managed upload proof fixture
kvz Jun 1, 2026
155ab61
Update managed upload proof fixture
kvz Jun 1, 2026
dc2a04c
Update managed upload proof fixture
kvz Jun 1, 2026
9c423cb
Update managed upload proof fixture
kvz Jun 1, 2026
2240090
Update generated protocol contract fixture
kvz Jun 1, 2026
96e1c77
Update generated managed upload contract
kvz Jun 1, 2026
4f080f0
Add devdock Transloadit TUS example
kvz Jun 1, 2026
346e13f
Normalize generated request facts
kvz Jun 2, 2026
6272743
Regenerate TUS protocol fixtures
kvz Jun 3, 2026
d1cf182
Regenerate TUS protocol response facts
kvz Jun 3, 2026
447c0d9
Merge remote-tracking branch 'origin/main' into tus-gen
kvz Jun 3, 2026
befe020
Use generated TUS default headers in specs
kvz Jun 3, 2026
05fda15
Resolve TUS operation IDs by role
kvz Jun 3, 2026
1a5d7a3
Update generated TUS concatenation facts
kvz Jun 3, 2026
a3a52c0
Add generated TUS request ID proof
kvz Jun 4, 2026
e91f4fd
Use effective TUS contract fixtures
kvz Jun 4, 2026
2dbafc8
Regenerate TUS event alternatives
kvz Jun 4, 2026
c8cdaf8
Format generated TUS event alternatives
kvz Jun 4, 2026
83646d0
Regenerate TUS extra event prefixes
kvz Jun 4, 2026
88ae884
Use generated TUS event key templates
kvz Jun 4, 2026
016823c
Use generic TUS extra event matching policy
kvz Jun 4, 2026
b496e3e
Use generated TUS fixture event keys
kvz Jun 4, 2026
f66a212
Use generated TUS retry decisions
kvz Jun 4, 2026
34aec24
Use generated TUS event kinds
kvz Jun 4, 2026
67b74e5
Use generated TUS completion facts
kvz Jun 4, 2026
04b91c4
Use generated TUS execution phases
kvz Jun 4, 2026
9f577eb
Use generated TUS source and URLs
kvz Jun 4, 2026
a8096b6
Use generated TUS input option entries
kvz Jun 4, 2026
412da2f
Use generated TUS runtime setup facts
kvz Jun 4, 2026
6692502
Drop raw input from TUS generated fixtures
kvz Jun 4, 2026
8842cfc
Regenerate TUS protocol runtime operation IDs
kvz Jun 4, 2026
04911ba
Regenerate TUS protocol fixture
kvz Jun 5, 2026
fb220f4
Drop generated TUS operation role lookup
kvz Jun 5, 2026
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ jobs:
deno-version: latest
- name: Install dependencies
run: npm ci
- name: Configure Puppeteer browser
if: matrix.suite == 'puppeteer'
run: |
chrome_bin="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium-browser || true)"
if [ -z "$chrome_bin" ]; then
rm -rf ~/.cache/puppeteer/chrome
npx puppeteer browsers install chrome
chrome_bin="$(node -e "process.stdout.write(require('puppeteer').executablePath())")"
fi
test -x "$chrome_bin"
echo "CHROME_BIN=$chrome_bin" >> "$GITHUB_ENV"
- name: Build
run: npm run build
- name: Test
Expand Down
165 changes: 165 additions & 0 deletions examples/api2-devdock-transloadit-assembly-upload/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import { Upload } from '../../lib.esm/node/index.js'

function fail(message) {
throw new Error(message)
}

function exampleDirname() {
return path.dirname(fileURLToPath(import.meta.url))
}

async function loadScenario() {
const scenarioPath =
process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(exampleDirname(), 'api2-scenario.json')

return JSON.parse(await readFile(scenarioPath, 'utf8'))
}

function readPath(value, pathParts, label) {
let current = value
for (const part of pathParts) {
if (Array.isArray(current) && Number.isInteger(part)) {
if (part >= current.length) {
fail(`${label} path ${JSON.stringify(pathParts)} index ${part} is out of range`)
}
current = current[part]
continue
}

if (
typeof current === 'object' &&
current !== null &&
!Array.isArray(current) &&
typeof part === 'string'
) {
if (!Object.hasOwn(current, part)) {
fail(`${label} path ${JSON.stringify(pathParts)} is missing key ${JSON.stringify(part)}`)
}
current = current[part]
continue
}

fail(`${label} path ${JSON.stringify(pathParts)} cannot read ${JSON.stringify(part)}`)
}

return current
}

function resolveValue(valueSpec, context, label) {
if (Object.hasOwn(valueSpec, 'value')) {
return valueSpec.value
}

const source = valueSpec.source
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
fail(`${label} value spec has no literal value or source`)
}

if (!Object.hasOwn(context, source.root)) {
fail(`${label} value source root ${JSON.stringify(source.root)} is unavailable`)
}

if (!Array.isArray(source.path)) {
fail(`${label} value source path must be an array`)
}

return readPath(context[source.root], source.path, label)
}

function scalarString(value) {
if (value === null) {
return 'null'
}

if (typeof value === 'boolean') {
return value ? 'true' : 'false'
}

return String(value)
}

function scenarioBytes(uploadConfig) {
const source = uploadConfig.source
if (source.kind !== 'bytes') {
fail(`unsupported scenario source kind ${JSON.stringify(source.kind)}`)
}

if (source.encoding !== 'utf8') {
fail(`unsupported scenario source encoding ${JSON.stringify(source.encoding)}`)
}

return Buffer.from(source.value, 'utf8')
}

function uploadMetadata(uploadConfig, scenario, createResponse) {
const context = { createResponse, scenario }
const metadata = {}
for (const field of uploadConfig.metadata) {
metadata[field.name] = scalarString(resolveValue(field.value, context, field.name))
}

return metadata
}

function retryDelays(retries) {
if (!Number.isInteger(retries) || retries < 0) {
fail(`unsupported retry count ${JSON.stringify(retries)}`)
}

return Array.from({ length: retries }, () => 0)
}

async function uploadWithTus(scenario, createResponse) {
const uploadConfig = scenario.upload
const context = { createResponse, scenario }
const endpoint = scalarString(resolveValue(uploadConfig.tusUrl, context, 'tusUrl'))
const content = scenarioBytes(uploadConfig)
if (uploadConfig.chunkSize !== 'full-file') {
fail(`unsupported chunk size policy ${JSON.stringify(uploadConfig.chunkSize)}`)
}

const upload = new Upload(content, {
endpoint,
chunkSize: content.length,
metadata: uploadMetadata(uploadConfig, scenario, createResponse),
retryDelays: retryDelays(uploadConfig.retries),
})

await new Promise((resolve, reject) => {
upload.options.onError = reject
upload.options.onSuccess = resolve
upload.start()
})

if (!upload.url) {
fail('TUS upload did not expose an upload URL')
}

return upload.url
}

async function writeResult(uploadUrl) {
const resultPath = process.env.API2_SDK_EXAMPLE_RESULT
if (!resultPath) {
return
}

await writeFile(resultPath, `${JSON.stringify({ uploadUrl }, undefined, 2)}\n`)
}

async function main() {
const scenario = await loadScenario()
const createResponse = scenario.prepared.createResponse
const uploadUrl = await uploadWithTus(scenario, createResponse)
await writeResult(uploadUrl)
console.log(`TypeScript TUS SDK devdock scenario ${scenario.scenarioId} uploaded to ${uploadUrl}`)
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
34 changes: 23 additions & 11 deletions lib/DetailedError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { HttpRequest, HttpResponse } from './options.js'
import type { TusDetailedErrorRequestContext } from './protocol_generated.js'
import {
TUS_REQUEST_ID_HEADER_NAME,
tusDetailedErrorEmptyResponseBody,
tusDetailedErrorMessage,
tusDetailedErrorMissingValue,
} from './protocol_generated.js'

export class DetailedError extends Error {
originalRequest?: HttpRequest
Expand All @@ -14,18 +21,23 @@ export class DetailedError extends Error {
this.originalResponse = res
this.causingError = causingErr

if (causingErr != null) {
message += `, caused by ${causingErr.toString()}`
}

let requestContext: TusDetailedErrorRequestContext | undefined
if (req != null) {
const requestId = req.getHeader('X-Request-ID') || 'n/a'
const method = req.getMethod()
const url = req.getURL()
const status = res ? res.getStatus() : 'n/a'
const body = res ? res.getBody() || '' : 'n/a'
message += `, originated from request (method: ${method}, url: ${url}, response code: ${status}, response text: ${body}, request id: ${requestId})`
requestContext = {
body: res
? res.getBody() || tusDetailedErrorEmptyResponseBody()
: tusDetailedErrorMissingValue(),
method: req.getMethod(),
requestId: req.getHeader(TUS_REQUEST_ID_HEADER_NAME) || tusDetailedErrorMissingValue(),
status: res ? res.getStatus() : tusDetailedErrorMissingValue(),
url: req.getURL(),
}
}
this.message = message

this.message = tusDetailedErrorMessage({
baseMessage: message,
cause: causingErr?.toString(),
requestContext,
})
}
}
21 changes: 12 additions & 9 deletions lib/browser/BrowserFileReader.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
openFile as openBaseFile,
supportedTypes as supportedBaseTypes,
} from '../commonFileReader.js'
import { openFile as openBaseFile } from '../commonFileReader.js'
import type { FileReader, FileSource, UploadInput } from '../options.js'
import {
tusCommonSupportedFileSourceTypes,
tusReactNativeUriBlobFetchFailedMessage,
tusReactNativeUriUnsupportedMessage,
tusUnsupportedSourceTypeMessage,
} from '../protocol_generated.js'
import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js'
import { uriToBlob } from '../reactnative/uriToBlob.js'
import { BlobFileSource } from '../sources/BlobFileSource.js'
Expand All @@ -15,24 +18,24 @@ export class BrowserFileReader implements FileReader {
// the file blob, before uploading with tus.
if (isReactNativeFile(input)) {
if (!isReactNativePlatform()) {
throw new Error('tus: file objects with `uri` property is only supported in React Native')
throw new Error(tusReactNativeUriUnsupportedMessage())
}

try {
const blob = await uriToBlob(input.uri)
return new BlobFileSource(blob)
} catch (err) {
throw new Error(
`tus: cannot fetch \`file.uri\` as Blob, make sure the uri is correct and accessible. ${err}`,
)
throw new Error(tusReactNativeUriBlobFetchFailedMessage({ error: err }))
}
}

const fileSource = openBaseFile(input, chunkSize)
if (fileSource) return fileSource

throw new Error(
`in this environment the source object may only be an instance of: ${supportedBaseTypes.join(', ')}`,
tusUnsupportedSourceTypeMessage({
supportedTypes: tusCommonSupportedFileSourceTypes(),
}),
)
}
}
5 changes: 2 additions & 3 deletions lib/browser/FetchHttpStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
HttpStack,
SliceType,
} from '../options.js'
import { tusHttpStackNodeReadableBodyUnsupportedMessage } from '../protocol_generated.js'

// TODO: Add tests for this.
export class FetchHttpStack implements HttpStack {
Expand Down Expand Up @@ -51,9 +52,7 @@ class FetchRequest implements HttpRequest {

async send(body?: SliceType): Promise<FetchResponse> {
if (isNodeReadableStream(body)) {
throw new Error(
'Using a Node.js readable stream as HTTP request body is not supported using the Fetch API HTTP stack.',
)
throw new Error(tusHttpStackNodeReadableBodyUnsupportedMessage({ stackName: 'Fetch API' }))
}

const res = await fetch(this._url, {
Expand Down
9 changes: 7 additions & 2 deletions lib/browser/XHRHttpStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type {
HttpStack,
SliceType,
} from '../options.js'
import {
tusAbortErrorDescriptor,
tusHttpStackNodeReadableBodyUnsupportedMessage,
} from '../protocol_generated.js'

export class XHRHttpStack implements HttpStack {
createRequest(method: string, url: string): HttpRequest {
Expand Down Expand Up @@ -68,7 +72,7 @@ class XHRRequest implements HttpRequest {
send(body?: SliceType): Promise<HttpResponse> {
if (isNodeReadableStream(body)) {
throw new Error(
'Using a Node.js readable stream as HTTP request body is not supported using the XMLHttpRequest HTTP stack.',
tusHttpStackNodeReadableBodyUnsupportedMessage({ stackName: 'XMLHttpRequest' }),
)
}

Expand All @@ -82,7 +86,8 @@ class XHRRequest implements HttpRequest {
}

this._xhr.onabort = () => {
reject(new DOMException('Request was aborted', 'AbortError'))
const error = tusAbortErrorDescriptor()
reject(new DOMException(error.message, error.name))
}

this._xhr.send(body)
Expand Down
52 changes: 25 additions & 27 deletions lib/browser/fileSignature.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,40 @@
import type { ReactNativeFile, UploadInput, UploadOptions } from '../options.js'
import type { UploadInput, UploadOptions } from '../options.js'
import {
tusBrowserBlobFingerprint,
tusReactNativeFingerprint,
tusUnsupportedInputFingerprint,
} from '../protocol_generated.js'
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))
return Promise.resolve(
tusReactNativeFingerprint({
endpoint: options.endpoint,
exifJson: file.exif ? JSON.stringify(file.exif) : null,
name: file.name,
size: file.size,
}),
)
}

if (file instanceof Blob) {
return Promise.resolve(
//@ts-expect-error TODO: We have to check the input type here
// This can be fixed by moving the fingerprint function to the FileReader class
['tus-br', file.name, file.type, file.size, file.lastModified, options.endpoint].join('-'),
tusBrowserBlobFingerprint({
endpoint: options.endpoint,
lastModified:
'lastModified' in file && typeof file.lastModified === 'number'
? file.lastModified
: undefined,
name: 'name' in file && typeof file.name === 'string' ? file.name : undefined,
size: file.size,
type: file.type,
}),
)
}

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
return Promise.resolve(tusUnsupportedInputFingerprint())
}
Loading