From 89ed49ad27b27cb41d75a92c0a7ba26be83736c9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 10:49:40 +0200 Subject: [PATCH 001/155] Mark protocol versions as generated --- lib/options.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/options.ts b/lib/options.ts index 53a1e284a..9798c6d84 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -1,10 +1,18 @@ import type { Readable as NodeReadableStream } from 'node:stream' import type { DetailedError } from './DetailedError.js' +// + +// This block is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + export const PROTOCOL_TUS_V1 = 'tus-v1' export const PROTOCOL_IETF_DRAFT_03 = 'ietf-draft-03' export const PROTOCOL_IETF_DRAFT_05 = 'ietf-draft-05' +// + /** * ReactNativeFile describes the structure that is returned from the * Expo image picker (see https://docs.expo.dev/versions/latest/sdk/imagepicker/) From 6358760d30878a7b391424baaab71b732fe0e23e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 11:06:29 +0200 Subject: [PATCH 002/155] Use generated protocol contract in upload test --- test/spec/browser-index.js | 1 + test/spec/generated-protocol-contract.js | 325 ++++++++++++++++++ test/spec/node-index.js | 1 + test/spec/test-generated-protocol-contract.js | 112 ++++++ 4 files changed, 439 insertions(+) create mode 100644 test/spec/generated-protocol-contract.js create mode 100644 test/spec/test-generated-protocol-contract.js diff --git a/test/spec/browser-index.js b/test/spec/browser-index.js index 407b44679..0f21f9260 100644 --- a/test/spec/browser-index.js +++ b/test/spec/browser-index.js @@ -5,6 +5,7 @@ beforeEach(() => { }) import './test-common.js' +import './test-generated-protocol-contract.js' import './test-browser-specific.js' import './test-parallel-uploads.js' import './test-terminate.js' diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js new file mode 100644 index 000000000..f389e8aaf --- /dev/null +++ b/test/spec/generated-protocol-contract.js @@ -0,0 +1,325 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + +export const tusProtocolOperations = [ + { + operationId: 'discoverTusCapabilities', + role: 'capability-discovery', + method: 'OPTIONS', + path: '/resumable/files/', + request: { + bodyKind: 'empty', + contentType: null, + headerVariants: [], + }, + responses: [ + { + statusCode: 200, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Extension', + name: 'tus-extension', + required: true, + }, + { + displayName: 'Tus-Max-Size', + name: 'tus-max-size', + required: true, + }, + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Tus-Version', + name: 'tus-version', + required: true, + }, + ], + }, + ], + }, + ], + }, + { + operationId: 'createTusUpload', + role: 'creation', + method: 'POST', + path: '/resumable/files/', + request: { + bodyKind: 'empty', + contentType: null, + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Length', + name: 'upload-length', + required: true, + }, + { + displayName: 'Upload-Metadata', + name: 'upload-metadata', + required: true, + }, + ], + }, + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Defer-Length', + name: 'upload-defer-length', + required: true, + }, + { + displayName: 'Upload-Metadata', + name: 'upload-metadata', + required: true, + }, + ], + }, + ], + }, + responses: [ + { + statusCode: 201, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Location', + name: 'location', + required: true, + }, + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, + ], + }, + { + operationId: 'getTusUploadOffset', + role: 'offset-discovery', + method: 'HEAD', + path: '/resumable/files/{upload_id}', + request: { + bodyKind: 'empty', + contentType: null, + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, + responses: [ + { + statusCode: 200, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Length', + name: 'upload-length', + required: true, + }, + { + displayName: 'Upload-Offset', + name: 'upload-offset', + required: true, + }, + ], + }, + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Defer-Length', + name: 'upload-defer-length', + required: true, + }, + { + displayName: 'Upload-Offset', + name: 'upload-offset', + required: true, + }, + ], + }, + ], + }, + ], + }, + { + operationId: 'patchTusUpload', + role: 'upload-chunk', + method: 'PATCH', + path: '/resumable/files/{upload_id}', + request: { + bodyKind: 'binary', + contentType: 'application/offset+octet-stream', + headerVariants: [ + { + fields: [ + { + displayName: 'Content-Type', + name: 'content-type', + required: true, + }, + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Offset', + name: 'upload-offset', + required: true, + }, + ], + }, + ], + }, + responses: [ + { + statusCode: 204, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Offset', + name: 'upload-offset', + required: true, + }, + ], + }, + ], + }, + ], + }, + { + operationId: 'terminateTusUpload', + role: 'termination', + method: 'DELETE', + path: '/resumable/files/{upload_id}', + request: { + bodyKind: 'empty', + contentType: null, + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, + responses: [ + { + statusCode: 204, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, + ], + }, + { + operationId: 'downloadTusUpload', + role: 'download', + method: 'GET', + path: '/resumable/files/{upload_id}', + request: { + bodyKind: 'empty', + contentType: null, + headerVariants: [], + }, + responses: [ + { + statusCode: 200, + bodyKind: 'binary', + headerVariants: [], + }, + ], + }, +] + +export const tusClientFeatures = [ + { + featureId: 'singleUploadLifecycle', + operationIds: [ + 'createTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + ], + primitives: [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + }, + { + featureId: 'terminateUpload', + operationIds: [ + 'terminateTusUpload', + ], + primitives: [ + 'retry-with-backoff', + ], + }, +] diff --git a/test/spec/node-index.js b/test/spec/node-index.js index 8e53a2eb2..bf6bf6192 100644 --- a/test/spec/node-index.js +++ b/test/spec/node-index.js @@ -1,4 +1,5 @@ import './test-common.js' +import './test-generated-protocol-contract.js' import './test-node-specific.js' import './test-parallel-uploads.js' import './test-terminate.js' diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js new file mode 100644 index 000000000..3c5c2b41a --- /dev/null +++ b/test/spec/test-generated-protocol-contract.js @@ -0,0 +1,112 @@ +import { Upload } from 'tus-js-client' +import { tusClientFeatures, tusProtocolOperations } from './generated-protocol-contract.js' +import { getBlob, TestHttpStack, waitableFunction } from './helpers/utils.js' + +function getProtocolOperation(operationId) { + const operation = tusProtocolOperations.find((candidate) => candidate.operationId === operationId) + if (!operation) { + throw new Error(`Missing generated TUS protocol operation: ${operationId}`) + } + + return operation +} + +function getClientFeature(featureId) { + const feature = tusClientFeatures.find((candidate) => candidate.featureId === featureId) + if (!feature) { + throw new Error(`Missing generated TUS client feature: ${featureId}`) + } + + return feature +} + +function requestMatchesHeaderVariant(requestHeaders, variant) { + return variant.fields + .filter((field) => field.required) + .every((field) => requestHeaders[field.displayName] != null) +} + +function expectRequestMatchesOperation(req, operation) { + expect(req.method).toBe(operation.method) + + if (operation.request.contentType) { + expect(req.requestHeaders['Content-Type']).toBe(operation.request.contentType) + } + + if (operation.request.headerVariants.length > 0) { + expect( + operation.request.headerVariants.some((variant) => + requestMatchesHeaderVariant(req.requestHeaders, variant), + ), + ).toBe(true) + } +} + +function getResponse(operation, statusCode) { + const response = operation.responses.find((candidate) => candidate.statusCode === statusCode) + if (!response) { + throw new Error( + `Missing generated response status ${statusCode} for ${operation.operationId}`, + ) + } + + return response +} + +function responseHeadersFor(response, overrides) { + const headers = {} + const variant = response.headerVariants[0] + for (const field of variant?.fields ?? []) { + if (!field.required) continue + headers[field.displayName] = overrides[field.displayName] ?? '1.0.0' + } + + return headers +} + +describe('generated TUS protocol contract', () => { + it('drives the simple upload lifecycle assertions from the generated contract', async () => { + const lifecycle = getClientFeature('singleUploadLifecycle') + const createOperation = getProtocolOperation(lifecycle.operationIds[0]) + const patchOperation = getProtocolOperation(lifecycle.operationIds[2]) + const testStack = new TestHttpStack() + const file = getBlob('hello world') + const options = { + httpStack: testStack, + endpoint: 'https://tus.io/uploads', + metadata: { + filename: 'hello.txt', + }, + onSuccess: waitableFunction('onSuccess'), + } + + const upload = new Upload(file, options) + upload.start() + + let req = await testStack.nextRequest() + expectRequestMatchesOperation(req, createOperation) + + const createResponse = getResponse(createOperation, 201) + req.respondWith({ + status: createResponse.statusCode, + responseHeaders: responseHeadersFor(createResponse, { + Location: 'https://tus.io/uploads/generated-contract', + }), + }) + + req = await testStack.nextRequest() + expectRequestMatchesOperation(req, patchOperation) + expect(req.bodySize).toBe(11) + + const patchResponse = getResponse(patchOperation, 204) + req.respondWith({ + status: patchResponse.statusCode, + responseHeaders: responseHeadersFor(patchResponse, { + 'Upload-Offset': '11', + }), + }) + + await options.onSuccess.toBeCalled() + expect(upload.url).toBe('https://tus.io/uploads/generated-contract') + }) +}) From 06d5692cedf71749382eb5033519556ac5f218f0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 11:11:36 +0200 Subject: [PATCH 003/155] Format generated protocol contract test --- test/spec/generated-protocol-contract.js | 14 +++----------- test/spec/test-generated-protocol-contract.js | 4 +--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index f389e8aaf..99fc7e95c 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -299,11 +299,7 @@ export const tusProtocolOperations = [ export const tusClientFeatures = [ { featureId: 'singleUploadLifecycle', - operationIds: [ - 'createTusUpload', - 'getTusUploadOffset', - 'patchTusUpload', - ], + operationIds: ['createTusUpload', 'getTusUploadOffset', 'patchTusUpload'], primitives: [ 'open-input-source', 'fingerprint-input', @@ -315,11 +311,7 @@ export const tusClientFeatures = [ }, { featureId: 'terminateUpload', - operationIds: [ - 'terminateTusUpload', - ], - primitives: [ - 'retry-with-backoff', - ], + operationIds: ['terminateTusUpload'], + primitives: ['retry-with-backoff'], }, ] diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3c5c2b41a..9618a0ac1 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -45,9 +45,7 @@ function expectRequestMatchesOperation(req, operation) { function getResponse(operation, statusCode) { const response = operation.responses.find((candidate) => candidate.statusCode === statusCode) if (!response) { - throw new Error( - `Missing generated response status ${statusCode} for ${operation.operationId}`, - ) + throw new Error(`Missing generated response status ${statusCode} for ${operation.operationId}`) } return response From 8078cea8a02033e0ff256ec16b75240795e62859 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 11:55:45 +0200 Subject: [PATCH 004/155] Expose generated TUS wire version --- test/spec/generated-protocol-contract.js | 7 +++++++ test/spec/test-generated-protocol-contract.js | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 99fc7e95c..89f9a1711 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2,6 +2,13 @@ // please report the issue instead of editing this file by hand; the source fix // belongs in the protocol contract generator so all TUS clients stay in sync. +export const tusWireVersions = [ + { + default: true, + value: '1.0.0', + }, +] + export const tusProtocolOperations = [ { operationId: 'discoverTusCapabilities', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 9618a0ac1..c994b9f41 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -1,5 +1,9 @@ import { Upload } from 'tus-js-client' -import { tusClientFeatures, tusProtocolOperations } from './generated-protocol-contract.js' +import { + tusClientFeatures, + tusProtocolOperations, + tusWireVersions, +} from './generated-protocol-contract.js' import { getBlob, TestHttpStack, waitableFunction } from './helpers/utils.js' function getProtocolOperation(operationId) { @@ -20,6 +24,15 @@ function getClientFeature(featureId) { return feature } +function getDefaultWireVersion() { + const versions = tusWireVersions.filter((candidate) => candidate.default) + if (versions.length !== 1) { + throw new Error('Generated TUS protocol contract must have exactly one default wire version') + } + + return versions[0].value +} + function requestMatchesHeaderVariant(requestHeaders, variant) { return variant.fields .filter((field) => field.required) @@ -54,9 +67,10 @@ function getResponse(operation, statusCode) { function responseHeadersFor(response, overrides) { const headers = {} const variant = response.headerVariants[0] + const defaultWireVersion = getDefaultWireVersion() for (const field of variant?.fields ?? []) { if (!field.required) continue - headers[field.displayName] = overrides[field.displayName] ?? '1.0.0' + headers[field.displayName] = overrides[field.displayName] ?? defaultWireVersion } return headers From 9d89ce8955bd74ca63857a2a3430348bda1e62cb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 15:34:38 +0200 Subject: [PATCH 005/155] Add generated TUS conformance scenarios --- test/spec/generated-protocol-contract.js | 309 +++++++++++++++++- test/spec/test-generated-protocol-contract.js | 266 ++++++++++++--- 2 files changed, 529 insertions(+), 46 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 89f9a1711..baf36b057 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -316,9 +316,316 @@ export const tusClientFeatures = [ 'abort-current-request', ], }, + { + featureId: 'resumeUpload', + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], + }, + { + featureId: 'deferredLengthUpload', + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-progress'], + }, + { + featureId: 'retryOffsetRecovery', + operationIds: ['createTusUpload', 'getTusUploadOffset', 'patchTusUpload'], + primitives: ['retry-with-backoff', 'recover-offset-after-error'], + }, { featureId: 'terminateUpload', operationIds: ['terminateTusUpload'], - primitives: ['retry-with-backoff'], + primitives: ['terminate-upload', 'retry-with-backoff'], + }, +] + +export const tusClientConformanceScenarios = [ + { + behavior: 'single-upload-lifecycle', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/generated-contract', + }, + featureId: 'singleUploadLifecycle', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/generated-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'singleUploadLifecycle', + }, + { + behavior: 'resume-from-previous-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/resume-contract', + }, + featureId: 'resumeUpload', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + storedUpload: { + fingerprint: 'contract-resume-fingerprint', + uploadUrl: 'https://tus.io/uploads/resume-contract', + }, + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], + requests: [ + { + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '5', + }, + statusCode: 200, + }, + url: 'upload', + }, + { + bodySize: 6, + headers: { + 'Upload-Offset': '5', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'resumeFromPreviousUpload', + }, + { + behavior: 'deferred-length-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/deferred-contract', + }, + featureId: 'deferredLengthUpload', + input: { + chunkSize: 100, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'readable-stream', + metadata: { + filename: 'hello.txt', + }, + uploadLengthDeferred: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-progress'], + requests: [ + { + absentHeaders: ['Upload-Length'], + headers: { + 'Upload-Defer-Length': '1', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/deferred-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'deferredLengthUpload', + }, + { + behavior: 'retry-patch-after-offset-recovery', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/retry-contract', + }, + featureId: 'retryOffsetRecovery', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + retryDelays: [0, 0], + }, + operationIds: ['createTusUpload', 'patchTusUpload', 'getTusUploadOffset', 'patchTusUpload'], + primitives: ['retry-with-backoff', 'recover-offset-after-error'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/retry-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + statusCode: 500, + }, + url: 'upload', + }, + { + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + statusCode: 200, + }, + url: 'upload', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'retryPatchAfterOffsetRecovery', + }, + { + behavior: 'terminate-with-retry', + completion: { + kind: 'terminated', + uploadUrl: 'https://tus.io/uploads/terminate-contract', + }, + featureId: 'terminateUpload', + input: { + chunkSize: 5, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + retryDelays: [0, 0], + }, + operationIds: ['createTusUpload', 'patchTusUpload', 'terminateTusUpload', 'terminateTusUpload'], + primitives: ['terminate-upload', 'retry-with-backoff'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/terminate-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 5, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '5', + }, + statusCode: 204, + }, + url: 'upload', + }, + { + operationId: 'terminateTusUpload', + response: { + statusCode: 423, + }, + url: 'upload', + }, + { + operationId: 'terminateTusUpload', + response: { + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'terminateWithRetry', }, ] diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index c994b9f41..0d69a8b43 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -1,5 +1,6 @@ import { Upload } from 'tus-js-client' import { + tusClientConformanceScenarios, tusClientFeatures, tusProtocolOperations, tusWireVersions, @@ -24,6 +25,17 @@ function getClientFeature(featureId) { return feature } +function getClientConformanceScenario(scenarioId) { + const scenario = tusClientConformanceScenarios.find( + (candidate) => candidate.scenarioId === scenarioId, + ) + if (!scenario) { + throw new Error(`Missing generated TUS client conformance scenario: ${scenarioId}`) + } + + return scenario +} + function getDefaultWireVersion() { const versions = tusWireVersions.filter((candidate) => candidate.default) if (versions.length !== 1) { @@ -55,70 +67,234 @@ function expectRequestMatchesOperation(req, operation) { } } -function getResponse(operation, statusCode) { - const response = operation.responses.find((candidate) => candidate.statusCode === statusCode) - if (!response) { - throw new Error(`Missing generated response status ${statusCode} for ${operation.operationId}`) - } - - return response +function getOperationResponse(operation, statusCode) { + return operation.responses.find((candidate) => candidate.statusCode === statusCode) } -function responseHeadersFor(response, overrides) { +function responseHeadersFor(response, overrides = {}) { const headers = {} const variant = response.headerVariants[0] const defaultWireVersion = getDefaultWireVersion() for (const field of variant?.fields ?? []) { if (!field.required) continue - headers[field.displayName] = overrides[field.displayName] ?? defaultWireVersion + if (overrides[field.displayName] != null) { + headers[field.displayName] = overrides[field.displayName] + continue + } + + if (field.displayName === 'Tus-Resumable') { + headers[field.displayName] = defaultWireVersion + continue + } + + throw new Error( + `Generated scenario response is missing ${field.displayName} for a required ${response.statusCode} response header`, + ) } + Object.assign(headers, overrides) + return headers } -describe('generated TUS protocol contract', () => { - it('drives the simple upload lifecycle assertions from the generated contract', async () => { - const lifecycle = getClientFeature('singleUploadLifecycle') - const createOperation = getProtocolOperation(lifecycle.operationIds[0]) - const patchOperation = getProtocolOperation(lifecycle.operationIds[2]) - const testStack = new TestHttpStack() - const file = getBlob('hello world') - const options = { - httpStack: testStack, - endpoint: 'https://tus.io/uploads', - metadata: { - filename: 'hello.txt', - }, - onSuccess: waitableFunction('onSuccess'), +function scenarioResponseHeadersFor(operation, response) { + const operationResponse = getOperationResponse(operation, response.statusCode) + if (!operationResponse) { + return response.headers ?? {} + } + + return responseHeadersFor(operationResponse, response.headers) +} + +function createReadableStream(content) { + let sent = false + const encoder = new TextEncoder() + return new ReadableStream({ + pull(controller) { + if (sent) { + controller.close() + return + } + + controller.enqueue(encoder.encode(content)) + sent = true + }, + }) +} + +function createScenarioInput(input) { + if (input.kind === 'blob') { + return getBlob(input.content) + } + + if (input.kind === 'readable-stream') { + return createReadableStream(input.content) + } + + throw new Error(`Unsupported generated TUS scenario input kind: ${input.kind}`) +} + +function makeStoredUploadStorage(storedUpload) { + return { + findAllUploads() { + return Promise.resolve([ + { + creationTime: new Date(0).toString(), + metadata: {}, + size: null, + uploadUrl: storedUpload.uploadUrl, + urlStorageKey: storedUpload.fingerprint, + }, + ]) + }, + findUploadsByFingerprint(fingerprint) { + expect(fingerprint).toBe(storedUpload.fingerprint) + return this.findAllUploads() + }, + addUpload() { + return Promise.resolve(storedUpload.fingerprint) + }, + removeUpload(urlStorageKey) { + expect(urlStorageKey).toBe(storedUpload.fingerprint) + return Promise.resolve() + }, + } +} + +function expectedUrlForScenarioRequest(scenario, request) { + if (request.url === 'endpoint') { + return scenario.input.endpointUrl + } + + const uploadUrl = + scenario.input.uploadUrl ?? + scenario.input.storedUpload?.uploadUrl ?? + scenario.completion.uploadUrl + if (!uploadUrl) { + throw new Error(`Generated scenario ${scenario.scenarioId} has no upload URL expectation`) + } + + return uploadUrl +} + +function expectScenarioRequest(req, scenario, request) { + const operation = getProtocolOperation(request.operationId) + + expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) + expectRequestMatchesOperation(req, operation) + + for (const [header, value] of Object.entries(request.headers ?? {})) { + expect(req.requestHeaders[header]).toBe(value) + } + + for (const header of request.absentHeaders ?? []) { + expect(req.requestHeaders[header]).toBe(undefined) + } + + if (request.bodySize != null) { + expect(req.bodySize).toBe(request.bodySize) + } + + req.respondWith({ + status: request.response.statusCode, + responseHeaders: scenarioResponseHeadersFor(operation, request.response), + }) +} + +async function startScenarioUpload(scenario, testStack) { + let upload + let terminatePromise + const options = { + endpoint: scenario.input.endpointUrl, + httpStack: testStack, + metadata: scenario.input.metadata ?? {}, + onError: waitableFunction('onError'), + onSuccess: waitableFunction('onSuccess'), + } + + if (scenario.input.chunkSize != null) { + options.chunkSize = scenario.input.chunkSize + } + + if (scenario.input.retryDelays != null) { + options.retryDelays = scenario.input.retryDelays + } + + if (scenario.input.uploadLengthDeferred != null) { + options.uploadLengthDeferred = scenario.input.uploadLengthDeferred + } + + if (scenario.input.uploadUrl != null) { + options.uploadUrl = scenario.input.uploadUrl + } + + if (scenario.input.storedUpload != null) { + options.fingerprint = jasmine + .createSpy('fingerprint') + .and.resolveTo(scenario.input.storedUpload.fingerprint) + options.urlStorage = makeStoredUploadStorage(scenario.input.storedUpload) + } else if (scenario.input.kind === 'readable-stream') { + options.fingerprint = jasmine.createSpy('fingerprint').and.resolveTo(null) + } + + if (scenario.behavior === 'terminate-with-retry') { + options.onChunkComplete = () => { + terminatePromise = upload.abort(true) } + } - const upload = new Upload(file, options) - upload.start() + upload = new Upload(createScenarioInput(scenario.input), options) - let req = await testStack.nextRequest() - expectRequestMatchesOperation(req, createOperation) + if (scenario.behavior === 'resume-from-previous-upload') { + const previousUploads = await upload.findPreviousUploads() + expect(previousUploads.length).toBe(1) + upload.resumeFromPreviousUpload(previousUploads[0]) + } - const createResponse = getResponse(createOperation, 201) - req.respondWith({ - status: createResponse.statusCode, - responseHeaders: responseHeadersFor(createResponse, { - Location: 'https://tus.io/uploads/generated-contract', - }), - }) + upload.start() + + return { options, terminatePromise: () => terminatePromise, upload } +} - req = await testStack.nextRequest() - expectRequestMatchesOperation(req, patchOperation) - expect(req.bodySize).toBe(11) +async function runGeneratedConformanceScenario(scenario) { + const feature = getClientFeature(scenario.featureId) + expect(feature.primitives).toEqual(jasmine.arrayContaining(scenario.primitives)) - const patchResponse = getResponse(patchOperation, 204) - req.respondWith({ - status: patchResponse.statusCode, - responseHeaders: responseHeadersFor(patchResponse, { - 'Upload-Offset': '11', - }), + const testStack = new TestHttpStack() + const { options, terminatePromise, upload } = await startScenarioUpload(scenario, testStack) + + for (const request of scenario.requests) { + const req = await testStack.nextRequest() + expectScenarioRequest(req, scenario, request) + } + + if (scenario.completion.kind === 'terminated') { + await terminatePromise() + expect(upload.url).toBe(scenario.completion.uploadUrl) + expect(options.onSuccess).not.toHaveBeenCalled() + expect(options.onError).not.toHaveBeenCalled() + return + } + + await options.onSuccess.toBeCalled() + expect(upload.url).toBe(scenario.completion.uploadUrl) + expect(options.onError).not.toHaveBeenCalled() +} + +describe('generated TUS protocol contract', () => { + for (const scenario of tusClientConformanceScenarios) { + it(`drives ${scenario.scenarioId} from the generated contract`, async () => { + await runGeneratedConformanceScenario(getClientConformanceScenario(scenario.scenarioId)) }) + } - await options.onSuccess.toBeCalled() - expect(upload.url).toBe('https://tus.io/uploads/generated-contract') + it('covers the expected first wave of generated conformance scenarios', () => { + expect(tusClientConformanceScenarios.map((scenario) => scenario.scenarioId)).toEqual([ + 'singleUploadLifecycle', + 'resumeFromPreviousUpload', + 'deferredLengthUpload', + 'retryPatchAfterOffsetRecovery', + 'terminateWithRetry', + ]) }) }) From 6130b34c1dfabd9d641f92c5107a1646a01d1f7a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 15:36:58 +0200 Subject: [PATCH 006/155] Stabilize parallel upload test ordering --- test/spec/test-node-specific.js | 74 +++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/test/spec/test-node-specific.js b/test/spec/test-node-specific.js index 290978efe..812d9e9d6 100644 --- a/test/spec/test-node-specific.js +++ b/test/spec/test-node-specific.js @@ -161,7 +161,6 @@ describe('tus', () => { }) it('should support parallelUploads', async () => { - // TODO: The ordering of requests is no longer deterministic, so we need to update this test // Create a temporary file const path = temp.path() fs.writeFileSync(path, 'hello world') @@ -181,65 +180,76 @@ describe('tus', () => { const upload = new Upload(file, options) upload.start() - let req = await testStack.nextRequest() - expect(req.url).toBe('https://tus.io/uploads') - expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') - expect(req.requestHeaders['Upload-Length']).toBe('5') - expect(req.requestHeaders['Upload-Concat']).toBe('partial') + const createRequests = [await testStack.nextRequest(), await testStack.nextRequest()].sort( + (a, b) => + Number(a.requestHeaders['Upload-Length']) - Number(b.requestHeaders['Upload-Length']), + ) - req.respondWith({ + const [firstPartCreateRequest, secondPartCreateRequest] = createRequests + expect(firstPartCreateRequest.url).toBe('https://tus.io/uploads') + expect(firstPartCreateRequest.method).toBe('POST') + expect(firstPartCreateRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expect(firstPartCreateRequest.requestHeaders['Upload-Length']).toBe('5') + expect(firstPartCreateRequest.requestHeaders['Upload-Concat']).toBe('partial') + + firstPartCreateRequest.respondWith({ status: 201, responseHeaders: { Location: 'https://tus.io/uploads/upload1', }, }) - req = await testStack.nextRequest() - expect(req.url).toBe('https://tus.io/uploads') - expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') - expect(req.requestHeaders['Upload-Length']).toBe('6') - expect(req.requestHeaders['Upload-Concat']).toBe('partial') + expect(secondPartCreateRequest.url).toBe('https://tus.io/uploads') + expect(secondPartCreateRequest.method).toBe('POST') + expect(secondPartCreateRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expect(secondPartCreateRequest.requestHeaders['Upload-Length']).toBe('6') + expect(secondPartCreateRequest.requestHeaders['Upload-Concat']).toBe('partial') - req.respondWith({ + secondPartCreateRequest.respondWith({ status: 201, responseHeaders: { Location: 'https://tus.io/uploads/upload2', }, }) - req = await testStack.nextRequest() - expect(req.url).toBe('https://tus.io/uploads/upload1') - expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') - expect(req.requestHeaders['Upload-Offset']).toBe('0') - expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') - expect(req.bodySize).toBe(5) + const patchRequests = [await testStack.nextRequest(), await testStack.nextRequest()].sort( + (a, b) => a.url.localeCompare(b.url), + ) - req.respondWith({ + const [firstPartPatchRequest, secondPartPatchRequest] = patchRequests + expect(firstPartPatchRequest.url).toBe('https://tus.io/uploads/upload1') + expect(firstPartPatchRequest.method).toBe('PATCH') + expect(firstPartPatchRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expect(firstPartPatchRequest.requestHeaders['Upload-Offset']).toBe('0') + expect(firstPartPatchRequest.requestHeaders['Content-Type']).toBe( + 'application/offset+octet-stream', + ) + expect(firstPartPatchRequest.bodySize).toBe(5) + + firstPartPatchRequest.respondWith({ status: 204, responseHeaders: { 'Upload-Offset': '5', }, }) - req = await testStack.nextRequest() - expect(req.url).toBe('https://tus.io/uploads/upload2') - expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') - expect(req.requestHeaders['Upload-Offset']).toBe('0') - expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') - expect(req.bodySize).toBe(6) + expect(secondPartPatchRequest.url).toBe('https://tus.io/uploads/upload2') + expect(secondPartPatchRequest.method).toBe('PATCH') + expect(secondPartPatchRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expect(secondPartPatchRequest.requestHeaders['Upload-Offset']).toBe('0') + expect(secondPartPatchRequest.requestHeaders['Content-Type']).toBe( + 'application/offset+octet-stream', + ) + expect(secondPartPatchRequest.bodySize).toBe(6) - req.respondWith({ + secondPartPatchRequest.respondWith({ status: 204, responseHeaders: { 'Upload-Offset': '6', }, }) - req = await testStack.nextRequest() + const req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') From be5447935cb32ede6cea9d5e341f0b99ee414c93 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 15:41:11 +0200 Subject: [PATCH 007/155] Relax parallel upload progress ordering --- test/spec/test-node-specific.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/spec/test-node-specific.js b/test/spec/test-node-specific.js index 812d9e9d6..dabf21509 100644 --- a/test/spec/test-node-specific.js +++ b/test/spec/test-node-specific.js @@ -268,7 +268,13 @@ describe('tus', () => { await options.onSuccess.toBeCalled() expect(upload.url).toBe('https://tus.io/uploads/upload3') - expect(options.onProgress).toHaveBeenCalledWith(5, 11) + expect( + options.onProgress.calls + .allArgs() + .some( + ([bytesSent, bytesTotal]) => bytesSent > 0 && bytesSent < 11 && bytesTotal === 11, + ), + ).toBe(true) expect(options.onProgress).toHaveBeenCalledWith(11, 11) }) From 851ed0cf72c1f593d3cb58697ef0a76d59da4afd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 16:19:13 +0200 Subject: [PATCH 008/155] Use generated TUS protocol facts --- lib/protocol_generated.ts | 49 ++++++++++++++++++++++++++++++++++++ lib/upload.ts | 53 ++++++++++++++++++++++----------------- 2 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 lib/protocol_generated.ts diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts new file mode 100644 index 000000000..abddd3e7a --- /dev/null +++ b/lib/protocol_generated.ts @@ -0,0 +1,49 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + +export const TUS_DEFAULT_PROTOCOL_VERSION = '1.0.0' + +export const TUS_HTTP_METHODS = { + DELETE: 'DELETE', + GET: 'GET', + HEAD: 'HEAD', + OPTIONS: 'OPTIONS', + PATCH: 'PATCH', + POST: 'POST', +} as const + +export const TUS_OPERATION_METHODS = { + DISCOVER_TUS_CAPABILITIES: 'OPTIONS', + CREATE_TUS_UPLOAD: 'POST', + GET_TUS_UPLOAD_OFFSET: 'HEAD', + PATCH_TUS_UPLOAD: 'PATCH', + TERMINATE_TUS_UPLOAD: 'DELETE', + DOWNLOAD_TUS_UPLOAD: 'GET', +} as const + +export const TUS_HEADERS = { + CONTENT_TYPE: 'Content-Type', + LOCATION: 'Location', + TUS_EXTENSION: 'Tus-Extension', + TUS_MAX_SIZE: 'Tus-Max-Size', + TUS_RESUMABLE: 'Tus-Resumable', + TUS_VERSION: 'Tus-Version', + UPLOAD_DEFER_LENGTH: 'Upload-Defer-Length', + UPLOAD_LENGTH: 'Upload-Length', + UPLOAD_METADATA: 'Upload-Metadata', + UPLOAD_OFFSET: 'Upload-Offset', +} as const + +export const TUS_REQUEST_CONTENT_TYPES = { + PATCH_TUS_UPLOAD: 'application/offset+octet-stream', +} as const + +export const TUS_RESPONSE_STATUS_CODES = { + DISCOVER_TUS_CAPABILITIES_200: 200, + CREATE_TUS_UPLOAD_201: 201, + GET_TUS_UPLOAD_OFFSET_200: 200, + PATCH_TUS_UPLOAD_204: 204, + TERMINATE_TUS_UPLOAD_204: 204, + DOWNLOAD_TUS_UPLOAD_200: 200, +} as const diff --git a/lib/upload.ts b/lib/upload.ts index 3cebb34cc..f748426bf 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -16,6 +16,13 @@ import { type UploadInput, type UploadOptions, } from './options.js' +import { + TUS_DEFAULT_PROTOCOL_VERSION, + TUS_HEADERS, + TUS_HTTP_METHODS, + TUS_REQUEST_CONTENT_TYPES, + TUS_RESPONSE_STATUS_CODES, +} from './protocol_generated.js' import { uuid } from './uuid.js' export const defaultOptions = { @@ -380,13 +387,13 @@ export class BaseUpload { if (this.options.endpoint == null) { throw new Error('tus: Expected options.endpoint to be set') } - const req = this._openRequest('POST', this.options.endpoint) + const req = this._openRequest(TUS_HTTP_METHODS.POST, this.options.endpoint) req.setHeader('Upload-Concat', `final;${this._parallelUploadUrls.join(' ')}`) // Add metadata if values have been added const metadata = encodeMetadata(this.options.metadata) if (metadata !== '') { - req.setHeader('Upload-Metadata', metadata) + req.setHeader(TUS_HEADERS.UPLOAD_METADATA, metadata) } let res: HttpResponse @@ -404,7 +411,7 @@ export class BaseUpload { throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) } - const location = res.getHeader('Location') + const location = res.getHeader(TUS_HEADERS.LOCATION) if (location == null) { throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) } @@ -593,21 +600,21 @@ export class BaseUpload { throw new Error('tus: unable to create upload because no endpoint is provided') } - const req = this._openRequest('POST', this.options.endpoint) + const req = this._openRequest(TUS_HTTP_METHODS.POST, this.options.endpoint) if (this._uploadLengthDeferred) { - req.setHeader('Upload-Defer-Length', '1') + req.setHeader(TUS_HEADERS.UPLOAD_DEFER_LENGTH, '1') } else { if (this._size == null) { throw new Error('tus: expected _size to be set') } - req.setHeader('Upload-Length', `${this._size}`) + req.setHeader(TUS_HEADERS.UPLOAD_LENGTH, `${this._size}`) } // Add metadata if values have been added const metadata = encodeMetadata(this.options.metadata) if (metadata !== '') { - req.setHeader('Upload-Metadata', metadata) + req.setHeader(TUS_HEADERS.UPLOAD_METADATA, metadata) } let res: HttpResponse @@ -636,7 +643,7 @@ export class BaseUpload { throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) } - const location = res.getHeader('Location') + const location = res.getHeader(TUS_HEADERS.LOCATION) if (location == null) { throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) } @@ -680,7 +687,7 @@ export class BaseUpload { if (this.url == null) { throw new Error('tus: Expected url to be set') } - const req = this._openRequest('HEAD', this.url) + const req = this._openRequest(TUS_HTTP_METHODS.HEAD, this.url) let res: HttpResponse try { @@ -725,7 +732,7 @@ export class BaseUpload { await this._createUpload() } - const offsetStr = res.getHeader('Upload-Offset') + const offsetStr = res.getHeader(TUS_HEADERS.UPLOAD_OFFSET) if (offsetStr === undefined) { throw new DetailedError('tus: missing Upload-Offset header', undefined, req, res) } @@ -734,11 +741,11 @@ export class BaseUpload { throw new DetailedError('tus: invalid Upload-Offset header', undefined, req, res) } - const deferLength = res.getHeader('Upload-Defer-Length') + const deferLength = res.getHeader(TUS_HEADERS.UPLOAD_DEFER_LENGTH) this._uploadLengthDeferred = deferLength === '1' // @ts-expect-error parseInt also handles undefined as we want it to - const length = Number.parseInt(res.getHeader('Upload-Length'), 10) + const length = Number.parseInt(res.getHeader(TUS_HEADERS.UPLOAD_LENGTH), 10) if ( Number.isNaN(length) && !this._uploadLengthDeferred && @@ -789,13 +796,13 @@ export class BaseUpload { // cases, you can tell tus-js-client to use a POST request with the // X-HTTP-Method-Override header for simulating a PATCH request. if (this.options.overridePatchMethod) { - req = this._openRequest('POST', this.url) - req.setHeader('X-HTTP-Method-Override', 'PATCH') + req = this._openRequest(TUS_HTTP_METHODS.POST, this.url) + req.setHeader('X-HTTP-Method-Override', TUS_HTTP_METHODS.PATCH) } else { - req = this._openRequest('PATCH', this.url) + req = this._openRequest(TUS_HTTP_METHODS.PATCH, this.url) } - req.setHeader('Upload-Offset', `${this._offset}`) + req.setHeader(TUS_HEADERS.UPLOAD_OFFSET, `${this._offset}`) let res: HttpResponse try { @@ -840,9 +847,9 @@ export class BaseUpload { }) if (this.options.protocol === PROTOCOL_TUS_V1) { - req.setHeader('Content-Type', 'application/offset+octet-stream') + req.setHeader(TUS_HEADERS.CONTENT_TYPE, TUS_REQUEST_CONTENT_TYPES.PATCH_TUS_UPLOAD) } else if (this.options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader('Content-Type', 'application/partial-upload') + req.setHeader(TUS_HEADERS.CONTENT_TYPE, 'application/partial-upload') } // The specified chunkSize may be Infinity or the calcluated end position @@ -867,7 +874,7 @@ export class BaseUpload { // upload size and can tell the tus server. if (this._uploadLengthDeferred && done) { this._size = this._offset + sizeOfValue - req.setHeader('Upload-Length', `${this._size}`) + req.setHeader(TUS_HEADERS.UPLOAD_LENGTH, `${this._size}`) this._uploadLengthDeferred = false } @@ -905,7 +912,7 @@ export class BaseUpload { */ private async _handleUploadResponse(req: HttpRequest, res: HttpResponse): Promise { // TODO: || '' is not very good. - const offset = Number.parseInt(res.getHeader('Upload-Offset') || '', 10) + const offset = Number.parseInt(res.getHeader(TUS_HEADERS.UPLOAD_OFFSET) || '', 10) if (Number.isNaN(offset)) { throw new DetailedError('tus: invalid or missing offset value', undefined, req, res) } @@ -1028,7 +1035,7 @@ function openRequest(method: string, url: string, options: UploadOptions): HttpR } else if (options.protocol === PROTOCOL_IETF_DRAFT_05) { req.setHeader('Upload-Draft-Interop-Version', '6') } else { - req.setHeader('Tus-Resumable', '1.0.0') + req.setHeader(TUS_HEADERS.TUS_RESUMABLE, TUS_DEFAULT_PROTOCOL_VERSION) } const headers = options.headers || {} @@ -1187,12 +1194,12 @@ function wait(delay: number) { * @return {Promise} The Promise will be resolved/rejected when the requests finish. */ export async function terminate(url: string, options: UploadOptions): Promise { - const req = openRequest('DELETE', url, options) + const req = openRequest(TUS_HTTP_METHODS.DELETE, url, options) try { const res = await sendRequest(req, undefined, options) // A 204 response indicates a successfull request - if (res.getStatus() === 204) { + if (res.getStatus() === TUS_RESPONSE_STATUS_CODES.TERMINATE_TUS_UPLOAD_204) { return } From 14830ecbe6ae67b6e6cb589078d302840e5f0f10 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 16:32:07 +0200 Subject: [PATCH 009/155] Use generated TUS client protocol constants --- lib/protocol_generated.ts | 18 ++++++++++++++++++ lib/upload.ts | 32 +++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index abddd3e7a..553e81c1b 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -29,10 +29,28 @@ export const TUS_HEADERS = { TUS_MAX_SIZE: 'Tus-Max-Size', TUS_RESUMABLE: 'Tus-Resumable', TUS_VERSION: 'Tus-Version', + UPLOAD_COMPLETE: 'Upload-Complete', + UPLOAD_CONCAT: 'Upload-Concat', UPLOAD_DEFER_LENGTH: 'Upload-Defer-Length', + UPLOAD_DRAFT_INTEROP_VERSION: 'Upload-Draft-Interop-Version', UPLOAD_LENGTH: 'Upload-Length', UPLOAD_METADATA: 'Upload-Metadata', UPLOAD_OFFSET: 'Upload-Offset', + X_HTTP_METHOD_OVERRIDE: 'X-HTTP-Method-Override', + X_REQUEST_ID: 'X-Request-ID', +} as const + +export const TUS_CONTENT_TYPES = { + PARTIAL_UPLOAD: 'application/partial-upload', +} as const + +export const TUS_HEADER_VALUES = { + UPLOAD_COMPLETE_FALSE: '?0', + UPLOAD_COMPLETE_TRUE: '?1', + UPLOAD_CONCAT_FINAL_PREFIX: 'final;', + UPLOAD_CONCAT_PARTIAL: 'partial', + UPLOAD_DRAFT_INTEROP_VERSION_03: '5', + UPLOAD_DRAFT_INTEROP_VERSION_05: '6', } as const export const TUS_REQUEST_CONTENT_TYPES = { diff --git a/lib/upload.ts b/lib/upload.ts index f748426bf..18f3932b4 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -17,7 +17,9 @@ import { type UploadOptions, } from './options.js' import { + TUS_CONTENT_TYPES, TUS_DEFAULT_PROTOCOL_VERSION, + TUS_HEADER_VALUES, TUS_HEADERS, TUS_HTTP_METHODS, TUS_REQUEST_CONTENT_TYPES, @@ -337,7 +339,7 @@ export class BaseUpload { // Add the header to indicate the this is a partial upload. headers: { ...this.options.headers, - 'Upload-Concat': 'partial', + [TUS_HEADERS.UPLOAD_CONCAT]: TUS_HEADER_VALUES.UPLOAD_CONCAT_PARTIAL, }, // Reject or resolve the promise if the upload errors or completes. onSuccess: resolve, @@ -388,7 +390,10 @@ export class BaseUpload { throw new Error('tus: Expected options.endpoint to be set') } const req = this._openRequest(TUS_HTTP_METHODS.POST, this.options.endpoint) - req.setHeader('Upload-Concat', `final;${this._parallelUploadUrls.join(' ')}`) + req.setHeader( + TUS_HEADERS.UPLOAD_CONCAT, + `${TUS_HEADER_VALUES.UPLOAD_CONCAT_FINAL_PREFIX}${this._parallelUploadUrls.join(' ')}`, + ) // Add metadata if values have been added const metadata = encodeMetadata(this.options.metadata) @@ -627,7 +632,7 @@ export class BaseUpload { this.options.protocol === PROTOCOL_IETF_DRAFT_03 || this.options.protocol === PROTOCOL_IETF_DRAFT_05 ) { - req.setHeader('Upload-Complete', '?0') + req.setHeader(TUS_HEADERS.UPLOAD_COMPLETE, TUS_HEADER_VALUES.UPLOAD_COMPLETE_FALSE) } res = await this._sendRequest(req) } @@ -797,7 +802,7 @@ export class BaseUpload { // X-HTTP-Method-Override header for simulating a PATCH request. if (this.options.overridePatchMethod) { req = this._openRequest(TUS_HTTP_METHODS.POST, this.url) - req.setHeader('X-HTTP-Method-Override', TUS_HTTP_METHODS.PATCH) + req.setHeader(TUS_HEADERS.X_HTTP_METHOD_OVERRIDE, TUS_HTTP_METHODS.PATCH) } else { req = this._openRequest(TUS_HTTP_METHODS.PATCH, this.url) } @@ -849,7 +854,7 @@ export class BaseUpload { if (this.options.protocol === PROTOCOL_TUS_V1) { req.setHeader(TUS_HEADERS.CONTENT_TYPE, TUS_REQUEST_CONTENT_TYPES.PATCH_TUS_UPLOAD) } else if (this.options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader(TUS_HEADERS.CONTENT_TYPE, 'application/partial-upload') + req.setHeader(TUS_HEADERS.CONTENT_TYPE, TUS_CONTENT_TYPES.PARTIAL_UPLOAD) } // The specified chunkSize may be Infinity or the calcluated end position @@ -898,7 +903,10 @@ export class BaseUpload { this.options.protocol === PROTOCOL_IETF_DRAFT_03 || this.options.protocol === PROTOCOL_IETF_DRAFT_05 ) { - req.setHeader('Upload-Complete', done ? '?1' : '?0') + req.setHeader( + TUS_HEADERS.UPLOAD_COMPLETE, + done ? TUS_HEADER_VALUES.UPLOAD_COMPLETE_TRUE : TUS_HEADER_VALUES.UPLOAD_COMPLETE_FALSE, + ) } this._emitProgress(this._offset, this._size) return await this._sendRequest(req, value) @@ -1031,9 +1039,15 @@ function openRequest(method: string, url: string, options: UploadOptions): HttpR const req = options.httpStack.createRequest(method, url) if (options.protocol === PROTOCOL_IETF_DRAFT_03) { - req.setHeader('Upload-Draft-Interop-Version', '5') + req.setHeader( + TUS_HEADERS.UPLOAD_DRAFT_INTEROP_VERSION, + TUS_HEADER_VALUES.UPLOAD_DRAFT_INTEROP_VERSION_03, + ) } else if (options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader('Upload-Draft-Interop-Version', '6') + req.setHeader( + TUS_HEADERS.UPLOAD_DRAFT_INTEROP_VERSION, + TUS_HEADER_VALUES.UPLOAD_DRAFT_INTEROP_VERSION_05, + ) } else { req.setHeader(TUS_HEADERS.TUS_RESUMABLE, TUS_DEFAULT_PROTOCOL_VERSION) } @@ -1045,7 +1059,7 @@ function openRequest(method: string, url: string, options: UploadOptions): HttpR if (options.addRequestId) { const requestId = uuid() - req.setHeader('X-Request-ID', requestId) + req.setHeader(TUS_HEADERS.X_REQUEST_ID, requestId) } return req From 11e0c64089d6840d8be13393019662610708a693 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 16:37:04 +0200 Subject: [PATCH 010/155] Use generated TUS operation methods --- lib/upload.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/upload.ts b/lib/upload.ts index 18f3932b4..8af9f0339 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -22,6 +22,7 @@ import { TUS_HEADER_VALUES, TUS_HEADERS, TUS_HTTP_METHODS, + TUS_OPERATION_METHODS, TUS_REQUEST_CONTENT_TYPES, TUS_RESPONSE_STATUS_CODES, } from './protocol_generated.js' @@ -389,7 +390,7 @@ export class BaseUpload { if (this.options.endpoint == null) { throw new Error('tus: Expected options.endpoint to be set') } - const req = this._openRequest(TUS_HTTP_METHODS.POST, this.options.endpoint) + const req = this._openRequest(TUS_OPERATION_METHODS.CREATE_TUS_UPLOAD, this.options.endpoint) req.setHeader( TUS_HEADERS.UPLOAD_CONCAT, `${TUS_HEADER_VALUES.UPLOAD_CONCAT_FINAL_PREFIX}${this._parallelUploadUrls.join(' ')}`, @@ -605,7 +606,7 @@ export class BaseUpload { throw new Error('tus: unable to create upload because no endpoint is provided') } - const req = this._openRequest(TUS_HTTP_METHODS.POST, this.options.endpoint) + const req = this._openRequest(TUS_OPERATION_METHODS.CREATE_TUS_UPLOAD, this.options.endpoint) if (this._uploadLengthDeferred) { req.setHeader(TUS_HEADERS.UPLOAD_DEFER_LENGTH, '1') @@ -692,7 +693,7 @@ export class BaseUpload { if (this.url == null) { throw new Error('tus: Expected url to be set') } - const req = this._openRequest(TUS_HTTP_METHODS.HEAD, this.url) + const req = this._openRequest(TUS_OPERATION_METHODS.GET_TUS_UPLOAD_OFFSET, this.url) let res: HttpResponse try { @@ -804,7 +805,7 @@ export class BaseUpload { req = this._openRequest(TUS_HTTP_METHODS.POST, this.url) req.setHeader(TUS_HEADERS.X_HTTP_METHOD_OVERRIDE, TUS_HTTP_METHODS.PATCH) } else { - req = this._openRequest(TUS_HTTP_METHODS.PATCH, this.url) + req = this._openRequest(TUS_OPERATION_METHODS.PATCH_TUS_UPLOAD, this.url) } req.setHeader(TUS_HEADERS.UPLOAD_OFFSET, `${this._offset}`) @@ -1208,7 +1209,7 @@ function wait(delay: number) { * @return {Promise} The Promise will be resolved/rejected when the requests finish. */ export async function terminate(url: string, options: UploadOptions): Promise { - const req = openRequest(TUS_HTTP_METHODS.DELETE, url, options) + const req = openRequest(TUS_OPERATION_METHODS.TERMINATE_TUS_UPLOAD, url, options) try { const res = await sendRequest(req, undefined, options) From 07bff4f35d13b9d27aec4aad79fdc3ff8c881351 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 17:10:38 +0200 Subject: [PATCH 011/155] Use generated TUS protocol helpers --- lib/protocol_generated.ts | 138 +++++++++++++++++++++++++++++++++++--- lib/upload.ts | 93 ++++++++++++------------- 2 files changed, 171 insertions(+), 60 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 553e81c1b..3c7ac32ff 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -22,6 +22,15 @@ export const TUS_OPERATION_METHODS = { DOWNLOAD_TUS_UPLOAD: 'GET', } as const +export const TUS_OPERATION_IDS = { + DISCOVER_TUS_CAPABILITIES: 'discoverTusCapabilities', + CREATE_TUS_UPLOAD: 'createTusUpload', + GET_TUS_UPLOAD_OFFSET: 'getTusUploadOffset', + PATCH_TUS_UPLOAD: 'patchTusUpload', + TERMINATE_TUS_UPLOAD: 'terminateTusUpload', + DOWNLOAD_TUS_UPLOAD: 'downloadTusUpload', +} as const + export const TUS_HEADERS = { CONTENT_TYPE: 'Content-Type', LOCATION: 'Location', @@ -41,16 +50,8 @@ export const TUS_HEADERS = { } as const export const TUS_CONTENT_TYPES = { - PARTIAL_UPLOAD: 'application/partial-upload', -} as const - -export const TUS_HEADER_VALUES = { - UPLOAD_COMPLETE_FALSE: '?0', - UPLOAD_COMPLETE_TRUE: '?1', - UPLOAD_CONCAT_FINAL_PREFIX: 'final;', - UPLOAD_CONCAT_PARTIAL: 'partial', - UPLOAD_DRAFT_INTEROP_VERSION_03: '5', - UPLOAD_DRAFT_INTEROP_VERSION_05: '6', + APPLICATION_OFFSET_OCTET_STREAM: 'application/offset+octet-stream', + APPLICATION_PARTIAL_UPLOAD: 'application/partial-upload', } as const export const TUS_REQUEST_CONTENT_TYPES = { @@ -65,3 +66,120 @@ export const TUS_RESPONSE_STATUS_CODES = { TERMINATE_TUS_UPLOAD_204: 204, DOWNLOAD_TUS_UPLOAD_200: 200, } as const + +export const TUS_SUPPORTED_PROTOCOLS: readonly string[] = [ + 'tus-v1', + 'ietf-draft-03', + 'ietf-draft-05', +] + +export const TUS_PROTOCOL_REQUEST_HEADERS: Record> = { + 'tus-v1': { + 'Tus-Resumable': '1.0.0', + }, + 'ietf-draft-03': { + 'Upload-Draft-Interop-Version': '5', + }, + 'ietf-draft-05': { + 'Upload-Draft-Interop-Version': '6', + }, +} + +export const TUS_PROTOCOL_CHUNK_CONTENT_TYPES: Record = { + 'ietf-draft-05': 'application/partial-upload', + 'tus-v1': 'application/offset+octet-stream', +} + +export const TUS_PROTOCOL_UPLOAD_COMPLETE_HEADERS: Record< + string, + { completeValue: string; incompleteValue: string; name: string } +> = { + 'ietf-draft-03': { + completeValue: '?1', + incompleteValue: '?0', + name: 'Upload-Complete', + }, + 'ietf-draft-05': { + completeValue: '?1', + incompleteValue: '?0', + name: 'Upload-Complete', + }, +} + +export const TUS_CONCATENATION = { + finalPrefix: 'final;', + headerName: 'Upload-Concat', + partialValue: 'partial', + uploadUrlSeparator: ' ', +} + +export const TUS_METHOD_OVERRIDES: Record< + string, + { headers: Record; method: string } +> = { + patchTusUpload: { + headers: { + 'X-HTTP-Method-Override': 'PATCH', + }, + method: 'POST', + }, +} + +export const TUS_REQUEST_ID_HEADER_NAME = 'X-Request-ID' + +export function tusRequestHeadersForProtocol(protocol: string): Record { + return { ...(TUS_PROTOCOL_REQUEST_HEADERS[protocol] ?? {}) } +} + +export function tusSupportsProtocol(protocol: string): boolean { + return TUS_SUPPORTED_PROTOCOLS.includes(protocol) +} + +export function tusChunkContentTypeForProtocol(protocol: string): string | undefined { + return TUS_PROTOCOL_CHUNK_CONTENT_TYPES[protocol] +} + +export function tusUploadCompleteHeaderForProtocol( + protocol: string, + done: boolean, +): { name: string; value: string } | undefined { + const header = TUS_PROTOCOL_UPLOAD_COMPLETE_HEADERS[protocol] + if (!header) { + return undefined + } + + return { + name: header.name, + value: done ? header.completeValue : header.incompleteValue, + } +} + +export function tusPartialUploadHeaders(): Record { + return { + [TUS_CONCATENATION.headerName]: TUS_CONCATENATION.partialValue, + } +} + +export function tusFinalUploadConcatValue(uploadUrls: readonly string[]): string { + return `${TUS_CONCATENATION.finalPrefix}${uploadUrls.join(TUS_CONCATENATION.uploadUrlSeparator)}` +} + +export function tusMethodOverrideForOperation( + operationId: string, +): { headers: Record; method: string } | undefined { + const override = TUS_METHOD_OVERRIDES[operationId] + if (!override) { + return undefined + } + + return { + headers: { ...override.headers }, + method: override.method, + } +} + +export function tusRequestIdHeaders(requestId: string): Record { + return { + [TUS_REQUEST_ID_HEADER_NAME]: requestId, + } +} diff --git a/lib/upload.ts b/lib/upload.ts index 8af9f0339..3830a5ef6 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -8,8 +8,6 @@ import { type FileSource, type HttpRequest, type HttpResponse, - PROTOCOL_IETF_DRAFT_03, - PROTOCOL_IETF_DRAFT_05, PROTOCOL_TUS_V1, type PreviousUpload, type SliceType, @@ -17,14 +15,18 @@ import { type UploadOptions, } from './options.js' import { - TUS_CONTENT_TYPES, - TUS_DEFAULT_PROTOCOL_VERSION, - TUS_HEADER_VALUES, TUS_HEADERS, - TUS_HTTP_METHODS, + TUS_OPERATION_IDS, TUS_OPERATION_METHODS, - TUS_REQUEST_CONTENT_TYPES, TUS_RESPONSE_STATUS_CODES, + tusChunkContentTypeForProtocol, + tusFinalUploadConcatValue, + tusMethodOverrideForOperation, + tusPartialUploadHeaders, + tusRequestHeadersForProtocol, + tusRequestIdHeaders, + tusSupportsProtocol, + tusUploadCompleteHeaderForProtocol, } from './protocol_generated.js' import { uuid } from './uuid.js' @@ -160,11 +162,7 @@ export class BaseUpload { return } - if ( - ![PROTOCOL_TUS_V1, PROTOCOL_IETF_DRAFT_03, PROTOCOL_IETF_DRAFT_05].includes( - this.options.protocol, - ) - ) { + if (!tusSupportsProtocol(this.options.protocol)) { this._emitError(new Error(`tus: unsupported protocol ${this.options.protocol}`)) return } @@ -340,7 +338,7 @@ export class BaseUpload { // Add the header to indicate the this is a partial upload. headers: { ...this.options.headers, - [TUS_HEADERS.UPLOAD_CONCAT]: TUS_HEADER_VALUES.UPLOAD_CONCAT_PARTIAL, + ...tusPartialUploadHeaders(), }, // Reject or resolve the promise if the upload errors or completes. onSuccess: resolve, @@ -390,11 +388,11 @@ export class BaseUpload { if (this.options.endpoint == null) { throw new Error('tus: Expected options.endpoint to be set') } + if (!this._parallelUploadUrls) { + throw new Error('tus: Expected _parallelUploadUrls to be set') + } const req = this._openRequest(TUS_OPERATION_METHODS.CREATE_TUS_UPLOAD, this.options.endpoint) - req.setHeader( - TUS_HEADERS.UPLOAD_CONCAT, - `${TUS_HEADER_VALUES.UPLOAD_CONCAT_FINAL_PREFIX}${this._parallelUploadUrls.join(' ')}`, - ) + req.setHeader(TUS_HEADERS.UPLOAD_CONCAT, tusFinalUploadConcatValue(this._parallelUploadUrls)) // Add metadata if values have been added const metadata = encodeMetadata(this.options.metadata) @@ -629,11 +627,12 @@ export class BaseUpload { this._offset = 0 res = await this._addChunkToRequest(req) } else { - if ( - this.options.protocol === PROTOCOL_IETF_DRAFT_03 || - this.options.protocol === PROTOCOL_IETF_DRAFT_05 - ) { - req.setHeader(TUS_HEADERS.UPLOAD_COMPLETE, TUS_HEADER_VALUES.UPLOAD_COMPLETE_FALSE) + const uploadCompleteHeader = tusUploadCompleteHeaderForProtocol( + this.options.protocol, + false, + ) + if (uploadCompleteHeader) { + req.setHeader(uploadCompleteHeader.name, uploadCompleteHeader.value) } res = await this._sendRequest(req) } @@ -800,10 +799,17 @@ export class BaseUpload { } // Some browser and servers may not support the PATCH method. For those // cases, you can tell tus-js-client to use a POST request with the - // X-HTTP-Method-Override header for simulating a PATCH request. + // generated method-override header for simulating a PATCH request. if (this.options.overridePatchMethod) { - req = this._openRequest(TUS_HTTP_METHODS.POST, this.url) - req.setHeader(TUS_HEADERS.X_HTTP_METHOD_OVERRIDE, TUS_HTTP_METHODS.PATCH) + const methodOverride = tusMethodOverrideForOperation(TUS_OPERATION_IDS.PATCH_TUS_UPLOAD) + if (!methodOverride) { + throw new Error('tus: expected PATCH method override to be available') + } + + req = this._openRequest(methodOverride.method, this.url) + for (const [name, value] of Object.entries(methodOverride.headers)) { + req.setHeader(name, value) + } } else { req = this._openRequest(TUS_OPERATION_METHODS.PATCH_TUS_UPLOAD, this.url) } @@ -852,10 +858,9 @@ export class BaseUpload { this._emitProgress(start + bytesSent, this._size) }) - if (this.options.protocol === PROTOCOL_TUS_V1) { - req.setHeader(TUS_HEADERS.CONTENT_TYPE, TUS_REQUEST_CONTENT_TYPES.PATCH_TUS_UPLOAD) - } else if (this.options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader(TUS_HEADERS.CONTENT_TYPE, TUS_CONTENT_TYPES.PARTIAL_UPLOAD) + const contentType = tusChunkContentTypeForProtocol(this.options.protocol) + if (contentType) { + req.setHeader(TUS_HEADERS.CONTENT_TYPE, contentType) } // The specified chunkSize may be Infinity or the calcluated end position @@ -900,14 +905,9 @@ export class BaseUpload { return await this._sendRequest(req) } - if ( - this.options.protocol === PROTOCOL_IETF_DRAFT_03 || - this.options.protocol === PROTOCOL_IETF_DRAFT_05 - ) { - req.setHeader( - TUS_HEADERS.UPLOAD_COMPLETE, - done ? TUS_HEADER_VALUES.UPLOAD_COMPLETE_TRUE : TUS_HEADER_VALUES.UPLOAD_COMPLETE_FALSE, - ) + const uploadCompleteHeader = tusUploadCompleteHeaderForProtocol(this.options.protocol, done) + if (uploadCompleteHeader) { + req.setHeader(uploadCompleteHeader.name, uploadCompleteHeader.value) } this._emitProgress(this._offset, this._size) return await this._sendRequest(req, value) @@ -1039,19 +1039,10 @@ function inStatusCategory(status: number, category: 100 | 200 | 300 | 400 | 500) function openRequest(method: string, url: string, options: UploadOptions): HttpRequest { const req = options.httpStack.createRequest(method, url) - if (options.protocol === PROTOCOL_IETF_DRAFT_03) { - req.setHeader( - TUS_HEADERS.UPLOAD_DRAFT_INTEROP_VERSION, - TUS_HEADER_VALUES.UPLOAD_DRAFT_INTEROP_VERSION_03, - ) - } else if (options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader( - TUS_HEADERS.UPLOAD_DRAFT_INTEROP_VERSION, - TUS_HEADER_VALUES.UPLOAD_DRAFT_INTEROP_VERSION_05, - ) - } else { - req.setHeader(TUS_HEADERS.TUS_RESUMABLE, TUS_DEFAULT_PROTOCOL_VERSION) + for (const [name, value] of Object.entries(tusRequestHeadersForProtocol(options.protocol))) { + req.setHeader(name, value) } + const headers = options.headers || {} for (const [name, value] of Object.entries(headers)) { @@ -1060,7 +1051,9 @@ function openRequest(method: string, url: string, options: UploadOptions): HttpR if (options.addRequestId) { const requestId = uuid() - req.setHeader(TUS_HEADERS.X_REQUEST_ID, requestId) + for (const [name, value] of Object.entries(tusRequestIdHeaders(requestId))) { + req.setHeader(name, value) + } } return req From 2dbc304b05a004882785c409472c9cec7b80838f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 18:08:35 +0200 Subject: [PATCH 012/155] Use generated TUS protocol plans --- lib/protocol_generated.ts | 469 ++++++++++++++++++ lib/upload.ts | 266 +++++----- test/spec/test-generated-protocol-contract.js | 2 + 3 files changed, 601 insertions(+), 136 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 3c7ac32ff..0091382ca 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -4,6 +4,8 @@ export const TUS_DEFAULT_PROTOCOL_VERSION = '1.0.0' +export const TUS_DEFAULT_CLIENT_PROTOCOL = 'tus-v1' + export const TUS_HTTP_METHODS = { DELETE: 'DELETE', GET: 'GET', @@ -22,6 +24,15 @@ export const TUS_OPERATION_METHODS = { DOWNLOAD_TUS_UPLOAD: 'GET', } as const +export const TUS_OPERATION_METHOD_BY_ID: Record = { + discoverTusCapabilities: 'OPTIONS', + createTusUpload: 'POST', + getTusUploadOffset: 'HEAD', + patchTusUpload: 'PATCH', + terminateTusUpload: 'DELETE', + downloadTusUpload: 'GET', +} + export const TUS_OPERATION_IDS = { DISCOVER_TUS_CAPABILITIES: 'discoverTusCapabilities', CREATE_TUS_UPLOAD: 'createTusUpload', @@ -67,12 +78,25 @@ export const TUS_RESPONSE_STATUS_CODES = { DOWNLOAD_TUS_UPLOAD_200: 200, } as const +export const TUS_OPERATION_RESPONSE_STATUS_CODES: Record = { + discoverTusCapabilities: [200], + createTusUpload: [201], + getTusUploadOffset: [200], + patchTusUpload: [204], + terminateTusUpload: [204], + downloadTusUpload: [200], +} + export const TUS_SUPPORTED_PROTOCOLS: readonly string[] = [ 'tus-v1', 'ietf-draft-03', 'ietf-draft-05', ] +export const TUS_PROTOCOLS_REQUIRING_KNOWN_UPLOAD_LENGTH_ON_OFFSET_RESPONSE: readonly string[] = [ + 'tus-v1', +] + export const TUS_PROTOCOL_REQUEST_HEADERS: Record> = { 'tus-v1': { 'Tus-Resumable': '1.0.0', @@ -113,6 +137,12 @@ export const TUS_CONCATENATION = { uploadUrlSeparator: ' ', } +export const TUS_METADATA_ENCODING = { + entrySeparator: ',', + keyValueSeparator: ' ', + valueEncoding: 'base64', +} + export const TUS_METHOD_OVERRIDES: Record< string, { headers: Record; method: string } @@ -127,10 +157,100 @@ export const TUS_METHOD_OVERRIDES: Record< export const TUS_REQUEST_ID_HEADER_NAME = 'X-Request-ID' +export const TUS_RETRY_POLICY = { + clientErrorStatusCategory: 400, + lockedStatusCode: 423, + retryableClientStatusCodes: [409, 423], + successStatusCategory: 200, +} + +export type TusNumericHeaderReadResult = + | { ok: false; reason: 'invalid' | 'missing' } + | { ok: true; value: number } + +export interface TusRequestPlan { + headers: Record + method: string + operationId: string + url: string +} + +export type TusUploadCreationResponseReadResult = + | { ok: false; reason: 'missingLocation' | 'unexpectedStatus' } + | { ok: true; location: string } + +export type TusUploadOffsetResponseReadResult = + | { ok: false; reason: 'invalidLength' | 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' } + | { ok: true; length: number | null; offset: number; uploadLengthDeferred: boolean } + +export type TusUploadChunkResponseReadResult = + | { ok: false; reason: 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' } + | { ok: true; offset: number } + +export function tusStatusInCategory(status: number, category: number): boolean { + return status >= category && status < category + 100 +} + +export function tusIsSuccessfulResponseStatus(status: number): boolean { + return tusStatusInCategory(status, TUS_RETRY_POLICY.successStatusCategory) +} + +export function tusIsClientErrorStatus(status: number): boolean { + return tusStatusInCategory(status, TUS_RETRY_POLICY.clientErrorStatusCategory) +} + +export function tusIsLockedStatus(status: number): boolean { + return status === TUS_RETRY_POLICY.lockedStatusCode +} + +export function tusShouldRetryStatus(status: number): boolean { + return ( + !tusIsClientErrorStatus(status) || TUS_RETRY_POLICY.retryableClientStatusCodes.includes(status) + ) +} + +export function tusExpectedResponseStatusForOperation( + operationId: string, + status: number, +): boolean { + return TUS_OPERATION_RESPONSE_STATUS_CODES[operationId]?.includes(status) ?? false +} + +export function tusRequiresKnownUploadLengthOnOffsetResponse(protocol: string): boolean { + return TUS_PROTOCOLS_REQUIRING_KNOWN_UPLOAD_LENGTH_ON_OFFSET_RESPONSE.includes(protocol) +} + export function tusRequestHeadersForProtocol(protocol: string): Record { return { ...(TUS_PROTOCOL_REQUEST_HEADERS[protocol] ?? {}) } } +export function tusRequestPlanForOperation({ + headers = {}, + operationId, + protocol, + url, +}: { + headers?: Record + operationId: string + protocol: string + url: string +}): TusRequestPlan { + const method = TUS_OPERATION_METHOD_BY_ID[operationId] + if (method == null) { + throw new Error(`Unknown TUS operation: ${operationId}`) + } + + return { + headers: { + ...tusRequestHeadersForProtocol(protocol), + ...headers, + }, + method, + operationId, + url, + } +} + export function tusSupportsProtocol(protocol: string): boolean { return TUS_SUPPORTED_PROTOCOLS.includes(protocol) } @@ -164,6 +284,234 @@ export function tusFinalUploadConcatValue(uploadUrls: readonly string[]): string return `${TUS_CONCATENATION.finalPrefix}${uploadUrls.join(TUS_CONCATENATION.uploadUrlSeparator)}` } +export function tusEncodeMetadata( + metadata: Record, + encodeMetadataValue: (value: string) => string, +): string { + return Object.entries(metadata) + .map( + ([key, value]) => + `${key}${TUS_METADATA_ENCODING.keyValueSeparator}${encodeMetadataValue(String(value))}`, + ) + .join(TUS_METADATA_ENCODING.entrySeparator) +} + +export function tusMetadataHeaders( + metadata: Record, + encodeMetadataValue: (value: string) => string, +): Record { + const encodedMetadata = tusEncodeMetadata(metadata, encodeMetadataValue) + if (encodedMetadata === '') { + return {} + } + + return { + [TUS_HEADERS.UPLOAD_METADATA]: encodedMetadata, + } +} + +export function tusCreateUploadHeaders({ + encodeMetadataValue, + metadata, + size, + uploadLengthDeferred, +}: { + encodeMetadataValue: (value: string) => string + metadata: Record + size: number | null + uploadLengthDeferred: boolean +}): Record { + return { + ...(uploadLengthDeferred + ? { [TUS_HEADERS.UPLOAD_DEFER_LENGTH]: '1' } + : size == null + ? {} + : { [TUS_HEADERS.UPLOAD_LENGTH]: `${size}` }), + ...tusMetadataHeaders(metadata, encodeMetadataValue), + } +} + +export function tusPatchUploadHeaders({ + offset, + size, +}: { + offset: number + size?: number +}): Record { + return { + [TUS_HEADERS.UPLOAD_OFFSET]: `${offset}`, + ...(size == null ? {} : { [TUS_HEADERS.UPLOAD_LENGTH]: `${size}` }), + } +} + +export function tusUploadLengthHeaders({ size }: { size: number }): Record { + return { + [TUS_HEADERS.UPLOAD_LENGTH]: `${size}`, + } +} + +export function tusFinalUploadHeaders({ + encodeMetadataValue, + metadata, + uploadUrls, +}: { + encodeMetadataValue: (value: string) => string + metadata: Record + uploadUrls: readonly string[] +}): Record { + return { + [TUS_HEADERS.UPLOAD_CONCAT]: tusFinalUploadConcatValue(uploadUrls), + ...tusMetadataHeaders(metadata, encodeMetadataValue), + } +} + +export function tusUploadCompleteHeaders({ + done, + protocol, +}: { + done: boolean + protocol: string +}): Record { + const uploadCompleteHeader = tusUploadCompleteHeaderForProtocol(protocol, done) + + return { + ...(uploadCompleteHeader ? { [uploadCompleteHeader.name]: uploadCompleteHeader.value } : {}), + } +} + +export function tusUploadBodyHeaders({ + done, + protocol, +}: { + done: boolean + protocol: string +}): Record { + const contentType = tusChunkContentTypeForProtocol(protocol) + + return { + ...(contentType ? { [TUS_HEADERS.CONTENT_TYPE]: contentType } : {}), + ...tusUploadCompleteHeaders({ done, protocol }), + } +} + +export function tusCreateUploadRequestPlan({ + encodeMetadataValue, + endpoint, + metadata, + protocol, + size, + uploadComplete, + uploadLengthDeferred, +}: { + encodeMetadataValue: (value: string) => string + endpoint: string + metadata: Record + protocol: string + size: number | null + uploadComplete?: boolean + uploadLengthDeferred: boolean +}): TusRequestPlan { + return tusRequestPlanForOperation({ + headers: { + ...tusCreateUploadHeaders({ + encodeMetadataValue, + metadata, + size, + uploadLengthDeferred, + }), + ...(uploadComplete == null + ? {} + : tusUploadCompleteHeaders({ done: uploadComplete, protocol })), + }, + operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, + protocol, + url: endpoint, + }) +} + +export function tusFinalUploadRequestPlan({ + encodeMetadataValue, + endpoint, + metadata, + protocol, + uploadUrls, +}: { + encodeMetadataValue: (value: string) => string + endpoint: string + metadata: Record + protocol: string + uploadUrls: readonly string[] +}): TusRequestPlan { + return tusRequestPlanForOperation({ + headers: tusFinalUploadHeaders({ + encodeMetadataValue, + metadata, + uploadUrls, + }), + operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, + protocol, + url: endpoint, + }) +} + +export function tusGetUploadOffsetRequestPlan({ + protocol, + uploadUrl, +}: { + protocol: string + uploadUrl: string +}): TusRequestPlan { + return tusRequestPlanForOperation({ + operationId: TUS_OPERATION_IDS.GET_TUS_UPLOAD_OFFSET, + protocol, + url: uploadUrl, + }) +} + +export function tusPatchUploadRequestPlan({ + offset, + overridePatchMethod, + protocol, + uploadUrl, +}: { + offset: number + overridePatchMethod: boolean + protocol: string + uploadUrl: string +}): TusRequestPlan { + const methodOverride = overridePatchMethod + ? tusMethodOverrideForOperation(TUS_OPERATION_IDS.PATCH_TUS_UPLOAD) + : undefined + const plan = tusRequestPlanForOperation({ + headers: { + ...(methodOverride?.headers ?? {}), + ...tusPatchUploadHeaders({ offset }), + }, + operationId: TUS_OPERATION_IDS.PATCH_TUS_UPLOAD, + protocol, + url: uploadUrl, + }) + + return { + ...plan, + method: methodOverride?.method ?? plan.method, + } +} + +export function tusTerminateUploadRequestPlan({ + protocol, + uploadUrl, +}: { + protocol: string + uploadUrl: string +}): TusRequestPlan { + return tusRequestPlanForOperation({ + operationId: TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, + protocol, + url: uploadUrl, + }) +} + export function tusMethodOverrideForOperation( operationId: string, ): { headers: Record; method: string } | undefined { @@ -183,3 +531,124 @@ export function tusRequestIdHeaders(requestId: string): Record { [TUS_REQUEST_ID_HEADER_NAME]: requestId, } } + +function tusReadNumericHeader( + getHeader: (headerName: string) => string | undefined, + headerName: string, +): TusNumericHeaderReadResult { + const value = getHeader(headerName) + if (value === undefined) { + return { ok: false, reason: 'missing' } + } + + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) { + return { ok: false, reason: 'invalid' } + } + + return { ok: true, value: parsed } +} + +export function tusReadUploadLocation( + getHeader: (headerName: string) => string | undefined, +): string | undefined { + return getHeader(TUS_HEADERS.LOCATION) +} + +export function tusReadUploadOffset( + getHeader: (headerName: string) => string | undefined, +): TusNumericHeaderReadResult { + return tusReadNumericHeader(getHeader, TUS_HEADERS.UPLOAD_OFFSET) +} + +export function tusReadUploadLength( + getHeader: (headerName: string) => string | undefined, +): TusNumericHeaderReadResult { + return tusReadNumericHeader(getHeader, TUS_HEADERS.UPLOAD_LENGTH) +} + +export function tusIsUploadLengthDeferred( + getHeader: (headerName: string) => string | undefined, +): boolean { + return getHeader(TUS_HEADERS.UPLOAD_DEFER_LENGTH) === '1' +} + +export function tusReadUploadCreationResponse({ + getHeader, + status, +}: { + getHeader: (headerName: string) => string | undefined + status: number +}): TusUploadCreationResponseReadResult { + if (!tusIsSuccessfulResponseStatus(status)) { + return { ok: false, reason: 'unexpectedStatus' } + } + + const location = tusReadUploadLocation(getHeader) + if (location == null) { + return { ok: false, reason: 'missingLocation' } + } + + return { ok: true, location } +} + +export function tusReadUploadOffsetResponse({ + getHeader, + protocol, + status, +}: { + getHeader: (headerName: string) => string | undefined + protocol: string + status: number +}): TusUploadOffsetResponseReadResult { + if (!tusIsSuccessfulResponseStatus(status)) { + return { ok: false, reason: 'unexpectedStatus' } + } + + const offsetResult = tusReadUploadOffset(getHeader) + if (!offsetResult.ok && offsetResult.reason === 'missing') { + return { ok: false, reason: 'missingOffset' } + } + if (!offsetResult.ok) { + return { ok: false, reason: 'invalidOffset' } + } + + const uploadLengthDeferred = tusIsUploadLengthDeferred(getHeader) + const lengthResult = tusReadUploadLength(getHeader) + if ( + !lengthResult.ok && + !uploadLengthDeferred && + tusRequiresKnownUploadLengthOnOffsetResponse(protocol) + ) { + return { ok: false, reason: 'invalidLength' } + } + + return { + ok: true, + length: lengthResult.ok ? lengthResult.value : null, + offset: offsetResult.value, + uploadLengthDeferred, + } +} + +export function tusReadUploadChunkResponse({ + getHeader, + status, +}: { + getHeader: (headerName: string) => string | undefined + status: number +}): TusUploadChunkResponseReadResult { + if (!tusIsSuccessfulResponseStatus(status)) { + return { ok: false, reason: 'unexpectedStatus' } + } + + const offsetResult = tusReadUploadOffset(getHeader) + if (!offsetResult.ok && offsetResult.reason === 'missing') { + return { ok: false, reason: 'missingOffset' } + } + if (!offsetResult.ok) { + return { ok: false, reason: 'invalidOffset' } + } + + return { ok: true, offset: offsetResult.value } +} diff --git a/lib/upload.ts b/lib/upload.ts index 3830a5ef6..39ec08f28 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -4,29 +4,36 @@ import { Base64 } from 'js-base64' import URL from 'url-parse' import { DetailedError } from './DetailedError.js' import { log } from './logger.js' -import { - type FileSource, - type HttpRequest, - type HttpResponse, - PROTOCOL_TUS_V1, - type PreviousUpload, - type SliceType, - type UploadInput, - type UploadOptions, +import type { + FileSource, + HttpRequest, + HttpResponse, + PreviousUpload, + SliceType, + UploadInput, + UploadOptions, } from './options.js' import { - TUS_HEADERS, - TUS_OPERATION_IDS, - TUS_OPERATION_METHODS, - TUS_RESPONSE_STATUS_CODES, - tusChunkContentTypeForProtocol, - tusFinalUploadConcatValue, - tusMethodOverrideForOperation, + TUS_DEFAULT_CLIENT_PROTOCOL, + type TusRequestPlan, + tusCreateUploadRequestPlan, + tusExpectedResponseStatusForOperation, + tusFinalUploadRequestPlan, + tusGetUploadOffsetRequestPlan, + tusIsClientErrorStatus, + tusIsLockedStatus, + tusIsSuccessfulResponseStatus, tusPartialUploadHeaders, - tusRequestHeadersForProtocol, + tusPatchUploadRequestPlan, + tusReadUploadChunkResponse, + tusReadUploadCreationResponse, + tusReadUploadOffsetResponse, tusRequestIdHeaders, + tusShouldRetryStatus, tusSupportsProtocol, - tusUploadCompleteHeaderForProtocol, + tusTerminateUploadRequestPlan, + tusUploadBodyHeaders, + tusUploadLengthHeaders, } from './protocol_generated.js' import { uuid } from './uuid.js' @@ -65,7 +72,7 @@ export const defaultOptions = { fileReader: undefined, httpStack: undefined, - protocol: PROTOCOL_TUS_V1 as UploadOptions['protocol'], + protocol: TUS_DEFAULT_CLIENT_PROTOCOL as UploadOptions['protocol'], } export class BaseUpload { @@ -391,14 +398,15 @@ export class BaseUpload { if (!this._parallelUploadUrls) { throw new Error('tus: Expected _parallelUploadUrls to be set') } - const req = this._openRequest(TUS_OPERATION_METHODS.CREATE_TUS_UPLOAD, this.options.endpoint) - req.setHeader(TUS_HEADERS.UPLOAD_CONCAT, tusFinalUploadConcatValue(this._parallelUploadUrls)) - - // Add metadata if values have been added - const metadata = encodeMetadata(this.options.metadata) - if (metadata !== '') { - req.setHeader(TUS_HEADERS.UPLOAD_METADATA, metadata) - } + const req = this._openRequest( + tusFinalUploadRequestPlan({ + endpoint: this.options.endpoint, + encodeMetadataValue, + metadata: this.options.metadata, + protocol: this.options.protocol, + uploadUrls: this._parallelUploadUrls, + }), + ) let res: HttpResponse try { @@ -411,12 +419,14 @@ export class BaseUpload { throw new DetailedError('tus: failed to concatenate parallel uploads', err, req, undefined) } - if (!inStatusCategory(res.getStatus(), 200)) { + const creationResponse = tusReadUploadCreationResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }) + if (!creationResponse.ok && creationResponse.reason === 'unexpectedStatus') { throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) } - - const location = res.getHeader(TUS_HEADERS.LOCATION) - if (location == null) { + if (!creationResponse.ok) { throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) } @@ -424,7 +434,7 @@ export class BaseUpload { throw new Error('tus: Expeced endpoint to be defined.') } - this.url = resolveUrl(this.options.endpoint, location) + this.url = resolveUrl(this.options.endpoint, creationResponse.location) log(`Created upload at ${this.url}`) await this._emitSuccess(res) @@ -604,22 +614,21 @@ export class BaseUpload { throw new Error('tus: unable to create upload because no endpoint is provided') } - const req = this._openRequest(TUS_OPERATION_METHODS.CREATE_TUS_UPLOAD, this.options.endpoint) - - if (this._uploadLengthDeferred) { - req.setHeader(TUS_HEADERS.UPLOAD_DEFER_LENGTH, '1') - } else { - if (this._size == null) { - throw new Error('tus: expected _size to be set') - } - req.setHeader(TUS_HEADERS.UPLOAD_LENGTH, `${this._size}`) + if (!this._uploadLengthDeferred && this._size == null) { + throw new Error('tus: expected _size to be set') } - // Add metadata if values have been added - const metadata = encodeMetadata(this.options.metadata) - if (metadata !== '') { - req.setHeader(TUS_HEADERS.UPLOAD_METADATA, metadata) - } + const req = this._openRequest( + tusCreateUploadRequestPlan({ + endpoint: this.options.endpoint, + encodeMetadataValue, + metadata: this.options.metadata, + protocol: this.options.protocol, + size: this._size, + uploadComplete: this.options.uploadDataDuringCreation ? undefined : false, + uploadLengthDeferred: this._uploadLengthDeferred, + }), + ) let res: HttpResponse try { @@ -627,13 +636,6 @@ export class BaseUpload { this._offset = 0 res = await this._addChunkToRequest(req) } else { - const uploadCompleteHeader = tusUploadCompleteHeaderForProtocol( - this.options.protocol, - false, - ) - if (uploadCompleteHeader) { - req.setHeader(uploadCompleteHeader.name, uploadCompleteHeader.value) - } res = await this._sendRequest(req) } } catch (err) { @@ -644,12 +646,14 @@ export class BaseUpload { throw new DetailedError('tus: failed to create upload', err, req, undefined) } - if (!inStatusCategory(res.getStatus(), 200)) { + const creationResponse = tusReadUploadCreationResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }) + if (!creationResponse.ok && creationResponse.reason === 'unexpectedStatus') { throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) } - - const location = res.getHeader(TUS_HEADERS.LOCATION) - if (location == null) { + if (!creationResponse.ok) { throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) } @@ -657,7 +661,7 @@ export class BaseUpload { throw new Error('tus: Expected options.endpoint to be set') } - this.url = resolveUrl(this.options.endpoint, location) + this.url = resolveUrl(this.options.endpoint, creationResponse.location) log(`Created upload at ${this.url}`) if (typeof this.options.onUploadUrlAvailable === 'function') { @@ -692,7 +696,12 @@ export class BaseUpload { if (this.url == null) { throw new Error('tus: Expected url to be set') } - const req = this._openRequest(TUS_OPERATION_METHODS.GET_TUS_UPLOAD_OFFSET, this.url) + const req = this._openRequest( + tusGetUploadOffsetRequestPlan({ + protocol: this.options.protocol, + uploadUrl: this.url, + }), + ) let res: HttpResponse try { @@ -706,17 +715,17 @@ export class BaseUpload { } const status = res.getStatus() - if (!inStatusCategory(status, 200)) { - // If the upload is locked (indicated by the 423 Locked status code), we + if (!tusIsSuccessfulResponseStatus(status)) { + // If the upload is locked, we // emit an error instead of directly starting a new upload. This way the // retry logic can catch the error and will retry the upload. An upload // is usually locked for a short period of time and will be available // afterwards. - if (status === 423) { + if (tusIsLockedStatus(status)) { throw new DetailedError('tus: upload is currently locked; retry later', undefined, req, res) } - if (inStatusCategory(status, 400)) { + if (tusIsClientErrorStatus(status)) { // Remove stored fingerprint and corresponding endpoint, // on client errors since the file can not be found await this._removeFromUrlStorage() @@ -735,30 +744,31 @@ export class BaseUpload { // Try to create a new upload this.url = null await this._createUpload() + return } - const offsetStr = res.getHeader(TUS_HEADERS.UPLOAD_OFFSET) - if (offsetStr === undefined) { + const offsetResponse = tusReadUploadOffsetResponse({ + getHeader: (headerName) => res.getHeader(headerName), + protocol: this.options.protocol, + status, + }) + if (!offsetResponse.ok && offsetResponse.reason === 'unexpectedStatus') { + throw new DetailedError('tus: unexpected response while resuming upload', undefined, req, res) + } + if (!offsetResponse.ok && offsetResponse.reason === 'missingOffset') { throw new DetailedError('tus: missing Upload-Offset header', undefined, req, res) } - const offset = Number.parseInt(offsetStr, 10) - if (Number.isNaN(offset)) { + if (!offsetResponse.ok && offsetResponse.reason === 'invalidOffset') { throw new DetailedError('tus: invalid Upload-Offset header', undefined, req, res) } - - const deferLength = res.getHeader(TUS_HEADERS.UPLOAD_DEFER_LENGTH) - this._uploadLengthDeferred = deferLength === '1' - - // @ts-expect-error parseInt also handles undefined as we want it to - const length = Number.parseInt(res.getHeader(TUS_HEADERS.UPLOAD_LENGTH), 10) - if ( - Number.isNaN(length) && - !this._uploadLengthDeferred && - this.options.protocol === PROTOCOL_TUS_V1 - ) { + if (!offsetResponse.ok) { throw new DetailedError('tus: invalid or missing length value', undefined, req, res) } + const offset = offsetResponse.offset + const length = offsetResponse.length + this._uploadLengthDeferred = offsetResponse.uploadLengthDeferred + if (typeof this.options.onUploadUrlAvailable === 'function') { await this.options.onUploadUrlAvailable() } @@ -767,7 +777,7 @@ export class BaseUpload { // Upload has already been completed and we do not need to send additional // data to the server - if (offset === length) { + if (length != null && offset === length) { this._emitProgress(length, length) await this._emitSuccess(res) return @@ -797,24 +807,14 @@ export class BaseUpload { if (this.url == null) { throw new Error('tus: Expected url to be set') } - // Some browser and servers may not support the PATCH method. For those - // cases, you can tell tus-js-client to use a POST request with the - // generated method-override header for simulating a PATCH request. - if (this.options.overridePatchMethod) { - const methodOverride = tusMethodOverrideForOperation(TUS_OPERATION_IDS.PATCH_TUS_UPLOAD) - if (!methodOverride) { - throw new Error('tus: expected PATCH method override to be available') - } - - req = this._openRequest(methodOverride.method, this.url) - for (const [name, value] of Object.entries(methodOverride.headers)) { - req.setHeader(name, value) - } - } else { - req = this._openRequest(TUS_OPERATION_METHODS.PATCH_TUS_UPLOAD, this.url) - } - - req.setHeader(TUS_HEADERS.UPLOAD_OFFSET, `${this._offset}`) + req = this._openRequest( + tusPatchUploadRequestPlan({ + offset: this._offset, + overridePatchMethod: this.options.overridePatchMethod, + protocol: this.options.protocol, + uploadUrl: this.url, + }), + ) let res: HttpResponse try { @@ -837,7 +837,7 @@ export class BaseUpload { ) } - if (!inStatusCategory(res.getStatus(), 200)) { + if (!tusIsSuccessfulResponseStatus(res.getStatus())) { throw new DetailedError('tus: unexpected response while uploading chunk', undefined, req, res) } @@ -858,11 +858,6 @@ export class BaseUpload { this._emitProgress(start + bytesSent, this._size) }) - const contentType = tusChunkContentTypeForProtocol(this.options.protocol) - if (contentType) { - req.setHeader(TUS_HEADERS.CONTENT_TYPE, contentType) - } - // The specified chunkSize may be Infinity or the calcluated end position // may exceed the file's size. In both cases, we limit the end position to // the input's total size for simpler calculations and correctness. @@ -885,10 +880,12 @@ export class BaseUpload { // upload size and can tell the tus server. if (this._uploadLengthDeferred && done) { this._size = this._offset + sizeOfValue - req.setHeader(TUS_HEADERS.UPLOAD_LENGTH, `${this._size}`) + setRequestHeaders(req, tusUploadLengthHeaders({ size: this._size })) this._uploadLengthDeferred = false } + setRequestHeaders(req, tusUploadBodyHeaders({ done, protocol: this.options.protocol })) + // The specified uploadSize might not match the actual amount of data that a source // provides. In these cases, we cannot successfully complete the upload, so we // rather error out and let the user know. If not, tus-js-client will be stuck @@ -905,10 +902,6 @@ export class BaseUpload { return await this._sendRequest(req) } - const uploadCompleteHeader = tusUploadCompleteHeaderForProtocol(this.options.protocol, done) - if (uploadCompleteHeader) { - req.setHeader(uploadCompleteHeader.name, uploadCompleteHeader.value) - } this._emitProgress(this._offset, this._size) return await this._sendRequest(req, value) } @@ -920,11 +913,17 @@ export class BaseUpload { * @api private */ private async _handleUploadResponse(req: HttpRequest, res: HttpResponse): Promise { - // TODO: || '' is not very good. - const offset = Number.parseInt(res.getHeader(TUS_HEADERS.UPLOAD_OFFSET) || '', 10) - if (Number.isNaN(offset)) { + const chunkResponse = tusReadUploadChunkResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }) + if (!chunkResponse.ok && chunkResponse.reason === 'unexpectedStatus') { + throw new DetailedError('tus: unexpected response while uploading chunk', undefined, req, res) + } + if (!chunkResponse.ok) { throw new DetailedError('tus: invalid or missing offset value', undefined, req, res) } + const offset = chunkResponse.offset this._emitProgress(offset, this._size) this._emitChunkComplete(offset - this._offset, offset, this._size) @@ -946,8 +945,8 @@ export class BaseUpload { * * @api private */ - private _openRequest(method: string, url: string): HttpRequest { - const req = openRequest(method, url, this.options) + private _openRequest(plan: TusRequestPlan): HttpRequest { + const req = openRequest(plan, this.options) this._req = req return req } @@ -1013,20 +1012,14 @@ export class BaseUpload { } } -function encodeMetadata(metadata: Record): string { - return Object.entries(metadata) - .map(([key, value]) => `${key} ${Base64.encode(String(value))}`) - .join(',') +function encodeMetadataValue(value: string): string { + return Base64.encode(value) } -/** - * Checks whether a given status is in the range of the expected category. - * For example, only a status between 200 and 299 will satisfy the category 200. - * - * @api private - */ -function inStatusCategory(status: number, category: 100 | 200 | 300 | 400 | 500): boolean { - return status >= category && status < category + 100 +function setRequestHeaders(req: HttpRequest, headers: Record): void { + for (const [name, value] of Object.entries(headers)) { + req.setHeader(name, value) + } } /** @@ -1036,12 +1029,10 @@ function inStatusCategory(status: number, category: 100 | 200 | 300 | 400 | 500) * * @api private */ -function openRequest(method: string, url: string, options: UploadOptions): HttpRequest { - const req = options.httpStack.createRequest(method, url) +function openRequest(plan: TusRequestPlan, options: UploadOptions): HttpRequest { + const req = options.httpStack.createRequest(plan.method, plan.url) - for (const [name, value] of Object.entries(tusRequestHeadersForProtocol(options.protocol))) { - req.setHeader(name, value) - } + setRequestHeaders(req, plan.headers) const headers = options.headers || {} @@ -1119,7 +1110,7 @@ function shouldRetry( // - retryDelays option is set // - we didn't exceed the maxium number of retries, yet, and // - this error was caused by a request or it's response and - // - the error is server error (i.e. not a status 4xx except a 409 or 423) or + // - the error has a retryable response status, or // a onShouldRetry is specified and returns true // - the browser does not indicate that we are offline const isNetworkError = 'originalRequest' in err && err.originalRequest != null @@ -1139,13 +1130,13 @@ function shouldRetry( } /** - * determines if the request should be retried. Will only retry if not a status 4xx except a 409 or 423 + * determines if the request should be retried. * @param {DetailedError} err * @returns {boolean} */ function defaultOnShouldRetry(err: DetailedError): boolean { const status = err.originalResponse ? err.originalResponse.getStatus() : 0 - return (!inStatusCategory(status, 400) || status === 409 || status === 423) && isOnline() + return tusShouldRetryStatus(status) && isOnline() } /** @@ -1202,12 +1193,15 @@ function wait(delay: number) { * @return {Promise} The Promise will be resolved/rejected when the requests finish. */ export async function terminate(url: string, options: UploadOptions): Promise { - const req = openRequest(TUS_OPERATION_METHODS.TERMINATE_TUS_UPLOAD, url, options) + const plan = tusTerminateUploadRequestPlan({ + protocol: options.protocol, + uploadUrl: url, + }) + const req = openRequest(plan, options) try { const res = await sendRequest(req, undefined, options) - // A 204 response indicates a successfull request - if (res.getStatus() === TUS_RESPONSE_STATUS_CODES.TERMINATE_TUS_UPLOAD_204) { + if (tusExpectedResponseStatusForOperation(plan.operationId, res.getStatus())) { return } diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 0d69a8b43..c7a0ee34b 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -56,6 +56,8 @@ function expectRequestMatchesOperation(req, operation) { if (operation.request.contentType) { expect(req.requestHeaders['Content-Type']).toBe(operation.request.contentType) + } else { + expect(req.requestHeaders['Content-Type']).toBeUndefined() } if (operation.request.headerVariants.length > 0) { From 24046285dd776a74a2f4de203b8f0fa6ce3061f2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 18:40:25 +0200 Subject: [PATCH 013/155] Drive more TUS scenarios from generated contract --- test/spec/generated-protocol-contract.js | 253 ++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 34 ++- 2 files changed, 282 insertions(+), 5 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index baf36b057..28632fea8 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -100,6 +100,49 @@ export const tusProtocolOperations = [ }, ], }, + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Concat', + name: 'upload-concat', + required: true, + }, + { + displayName: 'Upload-Length', + name: 'upload-length', + required: true, + }, + { + displayName: 'Upload-Metadata', + name: 'upload-metadata', + required: false, + }, + ], + }, + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + { + displayName: 'Upload-Concat', + name: 'upload-concat', + required: true, + }, + { + displayName: 'Upload-Metadata', + name: 'upload-metadata', + required: false, + }, + ], + }, ], }, responses: [ @@ -326,6 +369,21 @@ export const tusClientFeatures = [ operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['defer-upload-length', 'emit-progress'], }, + { + featureId: 'creationWithUpload', + operationIds: ['createTusUpload'], + primitives: ['upload-during-creation', 'emit-progress'], + }, + { + featureId: 'overridePatchMethod', + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['override-patch-method'], + }, + { + featureId: 'parallelUploadConcat', + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['concatenate-partial-uploads', 'emit-progress'], + }, { featureId: 'retryOffsetRecovery', operationIds: ['createTusUpload', 'getTusUploadOffset', 'patchTusUpload'], @@ -394,6 +452,44 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'singleUploadLifecycle', }, + { + behavior: 'creation-with-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', + }, + featureId: 'creationWithUpload', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + uploadDataDuringCreation: true, + }, + operationIds: ['createTusUpload'], + primitives: ['upload-during-creation', 'emit-progress'], + requests: [ + { + bodySize: 11, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/creation-with-upload-contract', + 'Upload-Offset': '11', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + ], + scenarioId: 'creationWithUpload', + }, { behavior: 'resume-from-previous-upload', completion: { @@ -493,6 +589,163 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'deferredLengthUpload', }, + { + behavior: 'override-patch-method', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/override-contract', + }, + featureId: 'overridePatchMethod', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + overridePatchMethod: true, + uploadUrl: 'https://tus.io/uploads/override-contract', + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['override-patch-method'], + requests: [ + { + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '3', + }, + statusCode: 200, + }, + uploadUrl: 'https://tus.io/uploads/override-contract', + url: 'upload', + }, + { + bodySize: 8, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '3', + 'X-HTTP-Method-Override': 'PATCH', + }, + method: 'POST', + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/override-contract', + url: 'upload', + }, + ], + scenarioId: 'overridePatchMethod', + }, + { + behavior: 'parallel-upload-concat', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/parallel-final', + }, + featureId: 'parallelUploadConcat', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + foo: 'hello', + }, + metadataForPartialUploads: { + test: 'world', + }, + parallelUploads: 2, + }, + operationIds: [ + 'createTusUpload', + 'createTusUpload', + 'patchTusUpload', + 'patchTusUpload', + 'createTusUpload', + ], + primitives: ['concatenate-partial-uploads', 'emit-progress'], + requests: [ + { + headers: { + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'Upload-Metadata': 'test d29ybGQ=', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/parallel-part-1', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + headers: { + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'Upload-Metadata': 'test d29ybGQ=', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/parallel-part-2', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 5, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '5', + }, + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/parallel-part-1', + url: 'upload', + }, + { + bodySize: 6, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '6', + }, + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/parallel-part-2', + url: 'upload', + }, + { + absentHeaders: ['Upload-Length'], + headers: { + 'Upload-Concat': + 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', + 'Upload-Metadata': 'foo aGVsbG8=', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/parallel-final', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + ], + scenarioId: 'parallelUploadConcat', + }, { behavior: 'retry-patch-after-offset-recovery', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index c7a0ee34b..3ec4fd98d 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -51,11 +51,12 @@ function requestMatchesHeaderVariant(requestHeaders, variant) { .every((field) => requestHeaders[field.displayName] != null) } -function expectRequestMatchesOperation(req, operation) { - expect(req.method).toBe(operation.method) +function expectRequestMatchesOperation(req, operation, request) { + expect(req.method).toBe(request.method ?? operation.method) - if (operation.request.contentType) { - expect(req.requestHeaders['Content-Type']).toBe(operation.request.contentType) + const expectedContentType = request.headers?.['Content-Type'] ?? operation.request.contentType + if (expectedContentType) { + expect(req.requestHeaders['Content-Type']).toBe(expectedContentType) } else { expect(req.requestHeaders['Content-Type']).toBeUndefined() } @@ -168,6 +169,10 @@ function expectedUrlForScenarioRequest(scenario, request) { return scenario.input.endpointUrl } + if (request.uploadUrl) { + return request.uploadUrl + } + const uploadUrl = scenario.input.uploadUrl ?? scenario.input.storedUpload?.uploadUrl ?? @@ -183,7 +188,7 @@ function expectScenarioRequest(req, scenario, request) { const operation = getProtocolOperation(request.operationId) expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) - expectRequestMatchesOperation(req, operation) + expectRequestMatchesOperation(req, operation, request) for (const [header, value] of Object.entries(request.headers ?? {})) { expect(req.requestHeaders[header]).toBe(value) @@ -218,10 +223,26 @@ async function startScenarioUpload(scenario, testStack) { options.chunkSize = scenario.input.chunkSize } + if (scenario.input.metadataForPartialUploads != null) { + options.metadataForPartialUploads = scenario.input.metadataForPartialUploads + } + + if (scenario.input.overridePatchMethod != null) { + options.overridePatchMethod = scenario.input.overridePatchMethod + } + + if (scenario.input.parallelUploads != null) { + options.parallelUploads = scenario.input.parallelUploads + } + if (scenario.input.retryDelays != null) { options.retryDelays = scenario.input.retryDelays } + if (scenario.input.uploadDataDuringCreation != null) { + options.uploadDataDuringCreation = scenario.input.uploadDataDuringCreation + } + if (scenario.input.uploadLengthDeferred != null) { options.uploadLengthDeferred = scenario.input.uploadLengthDeferred } @@ -293,8 +314,11 @@ describe('generated TUS protocol contract', () => { it('covers the expected first wave of generated conformance scenarios', () => { expect(tusClientConformanceScenarios.map((scenario) => scenario.scenarioId)).toEqual([ 'singleUploadLifecycle', + 'creationWithUpload', 'resumeFromPreviousUpload', 'deferredLengthUpload', + 'overridePatchMethod', + 'parallelUploadConcat', 'retryPatchAfterOffsetRecovery', 'terminateWithRetry', ]) From 9130f3a0fc6d7d1d5e6a9637034f395673274484 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 19:24:53 +0200 Subject: [PATCH 014/155] Use generated TUS client flow helpers --- lib/protocol_generated.ts | 429 ++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 318 +++++++++++++--------------- 2 files changed, 575 insertions(+), 172 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 0091382ca..d34f6ac24 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -164,6 +164,43 @@ export const TUS_RETRY_POLICY = { successStatusCategory: 200, } +export const TUS_FLOW_POLICY = { + messages: { + configuredUploadSizeMismatch: + 'upload was configured with a size of {expectedSize} bytes, but the source is done after {actualSize} bytes', + createMissingEndpoint: 'tus: unable to create upload because no endpoint is provided', + createMissingSize: 'tus: expected _size to be set', + invalidChunkOffset: 'tus: invalid or missing offset value', + invalidResumeLength: 'tus: invalid or missing length value', + invalidResumeOffset: 'tus: invalid Upload-Offset header', + lockedUpload: 'tus: upload is currently locked; retry later', + missingEndpointOrUploadUrl: 'tus: neither an endpoint or an upload URL is provided', + missingInput: 'tus: no file or stream to upload provided', + missingPatchUrl: 'tus: Expected url to be set', + missingResumeOffset: 'tus: missing Upload-Offset header', + parallelBoundariesLengthMismatch: + 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + parallelBoundariesWithoutParallelUploads: + 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + parallelUploadsWithDeferredLength: + 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + parallelUploadsWithUploadSize: + 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + parallelUploadsWithUploadUrl: + 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + resumeWithoutEndpoint: + 'tus: unable to resume upload (new upload cannot be created without an endpoint)', + retryDelaysNotArray: 'tus: the `retryDelays` option must either be an array or null', + unexpectedChunkResponse: 'tus: unexpected response while uploading chunk', + unexpectedCreateResponse: 'tus: unexpected response while creating upload', + unexpectedResumeResponse: 'tus: unexpected response while resuming upload', + unexpectedTerminateResponse: 'tus: unexpected response while terminating upload', + uploadLocationMissing: 'tus: invalid or missing Location header', + unsupportedProtocolPrefix: 'tus: unsupported protocol ', + }, + minimumParallelUploads: 2, +} + export type TusNumericHeaderReadResult = | { ok: false; reason: 'invalid' | 'missing' } | { ok: true; value: number } @@ -187,6 +224,398 @@ export type TusUploadChunkResponseReadResult = | { ok: false; reason: 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' } | { ok: true; offset: number } +export type TusUploadStartValidationReason = + | 'missingInput' + | 'unsupportedProtocol' + | 'missingEndpointOrUploadUrl' + | 'retryDelaysNotArray' + | 'parallelUploadsWithUploadUrl' + | 'parallelUploadsWithUploadSize' + | 'parallelUploadsWithDeferredLength' + | 'parallelBoundariesWithoutParallelUploads' + | 'parallelBoundariesLengthMismatch' + +export type TusUploadStartValidationResult = + | { ok: false; message: string; reason: TusUploadStartValidationReason } + | { ok: true } + +export interface TusUploadStartValidationInput { + hasCurrentUrl: boolean + hasEndpoint: boolean + hasFile: boolean + hasUploadSize: boolean + hasUploadUrl: boolean + parallelUploadBoundariesCount: number | null + parallelUploads: number + protocol: string + retryDelays: unknown + uploadLengthDeferred: boolean +} + +export type TusSingleUploadStartPlan = + | { action: 'create'; logMessage: string } + | { action: 'resumeCurrent'; logMessage: string; url: string } + | { action: 'resumeConfigured'; logMessage: string; url: string } + +export type TusResumeResponseStatusPlan = + | { action: 'create'; removeStoredUpload: boolean } + | { + action: 'fail' + message: string + reason: 'locked' | 'resumeWithoutEndpoint' + removeStoredUpload: boolean + } + | { action: 'readOffset' } + +export type TusCreateUploadValidationResult = + | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } + | { ok: true } + +export type TusDeferredUploadLengthPlan = + | { shouldDeclareLength: false } + | { shouldDeclareLength: true; size: number } + +export type TusUploadOffsetCompletionPlan = { complete: false } | { complete: true; length: number } + +export type TusConfiguredUploadSizeCheck = { ok: true } | { message: string; ok: false } + +export type TusUploadStoragePlan = + | { shouldStore: false } + | { fingerprint: string; shouldStore: true } + +function tusFormatFlowMessage(template: string, values: Record): string { + let message = template + for (const [name, value] of Object.entries(values)) { + message = message.split(`{${name}}`).join(String(value)) + } + + return message +} + +function tusUploadStartValidationError( + reason: TusUploadStartValidationReason, + message: string, +): TusUploadStartValidationResult { + return { ok: false, message, reason } +} + +export function tusValidateUploadStart({ + hasCurrentUrl, + hasEndpoint, + hasFile, + hasUploadSize, + hasUploadUrl, + parallelUploadBoundariesCount, + parallelUploads, + protocol, + retryDelays, + uploadLengthDeferred, +}: TusUploadStartValidationInput): TusUploadStartValidationResult { + if (!hasFile) { + return tusUploadStartValidationError('missingInput', TUS_FLOW_POLICY.messages.missingInput) + } + + if (!tusSupportsProtocol(protocol)) { + return tusUploadStartValidationError( + 'unsupportedProtocol', + `${TUS_FLOW_POLICY.messages.unsupportedProtocolPrefix}${protocol}`, + ) + } + + if (!hasEndpoint && !hasUploadUrl && !hasCurrentUrl) { + return tusUploadStartValidationError( + 'missingEndpointOrUploadUrl', + TUS_FLOW_POLICY.messages.missingEndpointOrUploadUrl, + ) + } + + if (retryDelays != null && !Array.isArray(retryDelays)) { + return tusUploadStartValidationError( + 'retryDelaysNotArray', + TUS_FLOW_POLICY.messages.retryDelaysNotArray, + ) + } + + if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads) { + if (hasUploadUrl) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadUrl', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadUrl, + ) + } + + if (hasUploadSize) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadSize', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadSize, + ) + } + + if (uploadLengthDeferred) { + return tusUploadStartValidationError( + 'parallelUploadsWithDeferredLength', + TUS_FLOW_POLICY.messages.parallelUploadsWithDeferredLength, + ) + } + } + + if (parallelUploadBoundariesCount != null) { + if (parallelUploads < TUS_FLOW_POLICY.minimumParallelUploads) { + return tusUploadStartValidationError( + 'parallelBoundariesWithoutParallelUploads', + TUS_FLOW_POLICY.messages.parallelBoundariesWithoutParallelUploads, + ) + } + + if (parallelUploads !== parallelUploadBoundariesCount) { + return tusUploadStartValidationError( + 'parallelBoundariesLengthMismatch', + TUS_FLOW_POLICY.messages.parallelBoundariesLengthMismatch, + ) + } + } + + return { ok: true } +} + +export function tusPlanSingleUploadStart({ + currentUrl, + uploadUrl, +}: { + currentUrl: string | null + uploadUrl: string | null | undefined +}): TusSingleUploadStartPlan { + if (currentUrl != null) { + return { + action: 'resumeCurrent', + logMessage: `Resuming upload from previous URL: ${currentUrl}`, + url: currentUrl, + } + } + + if (uploadUrl != null) { + return { + action: 'resumeConfigured', + logMessage: `Resuming upload from provided URL: ${uploadUrl}`, + url: uploadUrl, + } + } + + return { action: 'create', logMessage: 'Creating a new upload' } +} + +export function tusValidateCreateUpload({ + hasEndpoint, + size, + uploadLengthDeferred, +}: { + hasEndpoint: boolean + size: number | null + uploadLengthDeferred: boolean +}): TusCreateUploadValidationResult { + if (!hasEndpoint) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.createMissingEndpoint, + reason: 'missingEndpoint', + } + } + + if (!uploadLengthDeferred && size == null) { + return { ok: false, message: TUS_FLOW_POLICY.messages.createMissingSize, reason: 'missingSize' } + } + + return { ok: true } +} + +export function tusShouldSendUploadBodyDuringCreation({ + uploadDataDuringCreation, + uploadLengthDeferred, +}: { + uploadDataDuringCreation: boolean + uploadLengthDeferred: boolean +}): boolean { + return uploadDataDuringCreation && !uploadLengthDeferred +} + +export function tusCreateUploadCompleteValue({ + uploadDataDuringCreation, +}: { + uploadDataDuringCreation: boolean +}): boolean | undefined { + return uploadDataDuringCreation ? undefined : false +} + +export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | null }): boolean { + return size === 0 +} + +export function tusPlanResumeResponseStatus({ + hasEndpoint, + status, +}: { + hasEndpoint: boolean + status: number +}): TusResumeResponseStatusPlan { + if (tusIsSuccessfulResponseStatus(status)) { + return { action: 'readOffset' } + } + + if (tusIsLockedStatus(status)) { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.lockedUpload, + reason: 'locked', + removeStoredUpload: false, + } + } + + const removeStoredUpload = tusIsClientErrorStatus(status) + if (!hasEndpoint) { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.resumeWithoutEndpoint, + reason: 'resumeWithoutEndpoint', + removeStoredUpload, + } + } + + return { action: 'create', removeStoredUpload } +} + +export function tusUploadIsCompleteAfterOffset({ + length, + offset, +}: { + length: number | null + offset: number +}): boolean { + return length != null && offset === length +} + +export function tusPlanUploadCompletionAfterOffset({ + length, + offset, +}: { + length: number | null + offset: number +}): TusUploadOffsetCompletionPlan { + if (length == null || offset !== length) { + return { complete: false } + } + + return { complete: true, length } +} + +export function tusUploadIsCompleteAfterChunk({ + offset, + size, +}: { + offset: number + size: number | null +}): boolean { + return offset === size +} + +export function tusShouldResetRetryAttempt({ + offset, + offsetBeforeRetry, +}: { + offset: number + offsetBeforeRetry: number +}): boolean { + return offset > offsetBeforeRetry +} + +export function tusShouldStoreUpload({ + fingerprint, + hasUrlStorageKey, + storeFingerprintForResuming, +}: { + fingerprint: string | null + hasUrlStorageKey: boolean + storeFingerprintForResuming: boolean +}): boolean { + return storeFingerprintForResuming && fingerprint != null && !hasUrlStorageKey +} + +export function tusPlanUploadStorage({ + fingerprint, + hasUrlStorageKey, + storeFingerprintForResuming, +}: { + fingerprint: string | null + hasUrlStorageKey: boolean + storeFingerprintForResuming: boolean +}): TusUploadStoragePlan { + if (!storeFingerprintForResuming || fingerprint == null || hasUrlStorageKey) { + return { shouldStore: false } + } + + return { fingerprint, shouldStore: true } +} + +export function tusChunkEnd({ + chunkSize, + offset, + size, + uploadLengthDeferred, +}: { + chunkSize: number + offset: number + size: number | null + uploadLengthDeferred: boolean +}): number { + const end = offset + chunkSize + if ((end === Number.POSITIVE_INFINITY || (size != null && end > size)) && !uploadLengthDeferred) { + return size ?? end + } + + return end +} + +export function tusDeferredUploadLengthPlan({ + done, + offset, + uploadLengthDeferred, + valueSize, +}: { + done: boolean + offset: number + uploadLengthDeferred: boolean + valueSize: number +}): TusDeferredUploadLengthPlan { + if (!uploadLengthDeferred || !done) { + return { shouldDeclareLength: false } + } + + return { shouldDeclareLength: true, size: offset + valueSize } +} + +export function tusCheckConfiguredUploadSize({ + done, + newSize, + size, + uploadLengthDeferred, +}: { + done: boolean + newSize: number + size: number | null + uploadLengthDeferred: boolean +}): TusConfiguredUploadSizeCheck { + if (uploadLengthDeferred || !done || newSize === size) { + return { ok: true } + } + + return { + message: tusFormatFlowMessage(TUS_FLOW_POLICY.messages.configuredUploadSizeMismatch, { + actualSize: newSize, + expectedSize: size ?? 'unknown', + }), + ok: false, + } +} + export function tusStatusInCategory(status: number, category: number): boolean { return status >= category && status < category + 100 } diff --git a/lib/upload.ts b/lib/upload.ts index 39ec08f28..81ce98eb9 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -15,25 +15,36 @@ import type { } from './options.js' import { TUS_DEFAULT_CLIENT_PROTOCOL, + TUS_FLOW_POLICY, type TusRequestPlan, + tusCheckConfiguredUploadSize, + tusChunkEnd, + tusCreatedUploadCompletesWithoutPatch, + tusCreateUploadCompleteValue, tusCreateUploadRequestPlan, + tusDeferredUploadLengthPlan, tusExpectedResponseStatusForOperation, tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, - tusIsClientErrorStatus, - tusIsLockedStatus, - tusIsSuccessfulResponseStatus, tusPartialUploadHeaders, tusPatchUploadRequestPlan, + tusPlanResumeResponseStatus, + tusPlanSingleUploadStart, + tusPlanUploadCompletionAfterOffset, + tusPlanUploadStorage, tusReadUploadChunkResponse, tusReadUploadCreationResponse, tusReadUploadOffsetResponse, tusRequestIdHeaders, + tusShouldResetRetryAttempt, tusShouldRetryStatus, - tusSupportsProtocol, + tusShouldSendUploadBodyDuringCreation, tusTerminateUploadRequestPlan, tusUploadBodyHeaders, + tusUploadIsCompleteAfterChunk, tusUploadLengthHeaders, + tusValidateCreateUpload, + tusValidateUploadStart, } from './protocol_generated.js' import { uuid } from './uuid.js' @@ -164,72 +175,23 @@ export class BaseUpload { } start(): void { - if (!this.file) { - this._emitError(new Error('tus: no file or stream to upload provided')) - return - } - - if (!tusSupportsProtocol(this.options.protocol)) { - this._emitError(new Error(`tus: unsupported protocol ${this.options.protocol}`)) - return - } - - if (!this.options.endpoint && !this.options.uploadUrl && !this.url) { - this._emitError(new Error('tus: neither an endpoint or an upload URL is provided')) - return - } - - const { retryDelays } = this.options - if (retryDelays != null && Object.prototype.toString.call(retryDelays) !== '[object Array]') { - this._emitError(new Error('tus: the `retryDelays` option must either be an array or null')) + const startValidation = tusValidateUploadStart({ + hasCurrentUrl: this.url != null, + hasEndpoint: this.options.endpoint != null, + hasFile: Boolean(this.file), + hasUploadSize: this.options.uploadSize != null, + hasUploadUrl: this.options.uploadUrl != null, + parallelUploadBoundariesCount: this.options.parallelUploadBoundaries?.length ?? null, + parallelUploads: this.options.parallelUploads, + protocol: this.options.protocol, + retryDelays: this.options.retryDelays, + uploadLengthDeferred: this._uploadLengthDeferred, + }) + if (!startValidation.ok) { + this._emitError(new Error(startValidation.message)) return } - if (this.options.parallelUploads > 1) { - // Test which options are incompatible with parallel uploads. - if (this.options.uploadUrl != null) { - this._emitError( - new Error('tus: cannot use the `uploadUrl` option when parallelUploads is enabled'), - ) - return - } - - if (this.options.uploadSize != null) { - this._emitError( - new Error('tus: cannot use the `uploadSize` option when parallelUploads is enabled'), - ) - return - } - - if (this._uploadLengthDeferred) { - this._emitError( - new Error( - 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', - ), - ) - return - } - } - - if (this.options.parallelUploadBoundaries) { - if (this.options.parallelUploads <= 1) { - this._emitError( - new Error( - 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', - ), - ) - return - } - if (this.options.parallelUploads !== this.options.parallelUploadBoundaries.length) { - this._emitError( - new Error( - 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', - ), - ) - return - } - } - // Note: `start` does not return a Promise or await the preparation on purpose. // Its supposed to return immediately and start the upload in the background. this._prepareAndStartUpload().catch((err) => { @@ -392,15 +354,16 @@ export class BaseUpload { // creating the final upload. await Promise.all(uploads) - if (this.options.endpoint == null) { - throw new Error('tus: Expected options.endpoint to be set') + const endpoint = this.options.endpoint + if (endpoint == null) { + throw new Error(TUS_FLOW_POLICY.messages.createMissingEndpoint) } if (!this._parallelUploadUrls) { throw new Error('tus: Expected _parallelUploadUrls to be set') } const req = this._openRequest( tusFinalUploadRequestPlan({ - endpoint: this.options.endpoint, + endpoint, encodeMetadataValue, metadata: this.options.metadata, protocol: this.options.protocol, @@ -424,17 +387,18 @@ export class BaseUpload { status: res.getStatus(), }) if (!creationResponse.ok && creationResponse.reason === 'unexpectedStatus') { - throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) + throw new DetailedError( + TUS_FLOW_POLICY.messages.unexpectedCreateResponse, + undefined, + req, + res, + ) } if (!creationResponse.ok) { - throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.uploadLocationMissing, undefined, req, res) } - if (this.options.endpoint == null) { - throw new Error('tus: Expeced endpoint to be defined.') - } - - this.url = resolveUrl(this.options.endpoint, creationResponse.location) + this.url = resolveUrl(endpoint, creationResponse.location) log(`Created upload at ${this.url}`) await this._emitSuccess(res) @@ -452,21 +416,21 @@ export class BaseUpload { // aborted previously. this._aborted = false - // The upload had been started previously and we should reuse this URL. - if (this.url != null) { - log(`Resuming upload from previous URL: ${this.url}`) + const plan = tusPlanSingleUploadStart({ + currentUrl: this.url, + uploadUrl: this.options.uploadUrl, + }) + log(plan.logMessage) + + if (plan.action === 'resumeCurrent') { return await this._resumeUpload() } - // A URL has manually been specified, so we try to resume - if (this.options.uploadUrl != null) { - log(`Resuming upload from provided URL: ${this.options.uploadUrl}`) - this.url = this.options.uploadUrl + if (plan.action === 'resumeConfigured') { + this.url = plan.url return await this._resumeUpload() } - // An upload has not started for the file yet, so we start a new one - log('Creating a new upload') return await this._createUpload() } @@ -530,8 +494,12 @@ export class BaseUpload { // We will reset the attempt counter if // - we were already able to connect to the server (offset != null) and // - we were able to upload a small chunk of data to the server - const shouldResetDelays = this._offset != null && this._offset > this._offsetBeforeRetry - if (shouldResetDelays) { + if ( + tusShouldResetRetryAttempt({ + offset: this._offset, + offsetBeforeRetry: this._offsetBeforeRetry, + }) + ) { this._retryAttempt = 0 } @@ -610,29 +578,41 @@ export class BaseUpload { * @api private */ private async _createUpload(): Promise { - if (!this.options.endpoint) { - throw new Error('tus: unable to create upload because no endpoint is provided') + const endpoint = this.options.endpoint + const validation = tusValidateCreateUpload({ + hasEndpoint: endpoint != null, + size: this._size, + uploadLengthDeferred: this._uploadLengthDeferred, + }) + if (!validation.ok) { + throw new Error(validation.message) } - - if (!this._uploadLengthDeferred && this._size == null) { - throw new Error('tus: expected _size to be set') + if (endpoint == null) { + throw new Error(TUS_FLOW_POLICY.messages.createMissingEndpoint) } const req = this._openRequest( tusCreateUploadRequestPlan({ - endpoint: this.options.endpoint, + endpoint, encodeMetadataValue, metadata: this.options.metadata, protocol: this.options.protocol, size: this._size, - uploadComplete: this.options.uploadDataDuringCreation ? undefined : false, + uploadComplete: tusCreateUploadCompleteValue({ + uploadDataDuringCreation: this.options.uploadDataDuringCreation, + }), uploadLengthDeferred: this._uploadLengthDeferred, }), ) let res: HttpResponse try { - if (this.options.uploadDataDuringCreation && !this._uploadLengthDeferred) { + if ( + tusShouldSendUploadBodyDuringCreation({ + uploadDataDuringCreation: this.options.uploadDataDuringCreation, + uploadLengthDeferred: this._uploadLengthDeferred, + }) + ) { this._offset = 0 res = await this._addChunkToRequest(req) } else { @@ -651,24 +631,25 @@ export class BaseUpload { status: res.getStatus(), }) if (!creationResponse.ok && creationResponse.reason === 'unexpectedStatus') { - throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) + throw new DetailedError( + TUS_FLOW_POLICY.messages.unexpectedCreateResponse, + undefined, + req, + res, + ) } if (!creationResponse.ok) { - throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.uploadLocationMissing, undefined, req, res) } - if (this.options.endpoint == null) { - throw new Error('tus: Expected options.endpoint to be set') - } - - this.url = resolveUrl(this.options.endpoint, creationResponse.location) + this.url = resolveUrl(endpoint, creationResponse.location) log(`Created upload at ${this.url}`) if (typeof this.options.onUploadUrlAvailable === 'function') { await this.options.onUploadUrlAvailable() } - if (this._size === 0) { + if (tusCreatedUploadCompletesWithoutPatch({ size: this._size })) { // Nothing to upload and file was successfully created await this._emitSuccess(res) if (this._source) this._source.close() @@ -694,7 +675,7 @@ export class BaseUpload { */ private async _resumeUpload(): Promise { if (this.url == null) { - throw new Error('tus: Expected url to be set') + throw new Error(TUS_FLOW_POLICY.messages.missingPatchUrl) } const req = this._openRequest( tusGetUploadOffsetRequestPlan({ @@ -715,33 +696,19 @@ export class BaseUpload { } const status = res.getStatus() - if (!tusIsSuccessfulResponseStatus(status)) { - // If the upload is locked, we - // emit an error instead of directly starting a new upload. This way the - // retry logic can catch the error and will retry the upload. An upload - // is usually locked for a short period of time and will be available - // afterwards. - if (tusIsLockedStatus(status)) { - throw new DetailedError('tus: upload is currently locked; retry later', undefined, req, res) - } - - if (tusIsClientErrorStatus(status)) { - // Remove stored fingerprint and corresponding endpoint, - // on client errors since the file can not be found + const responseStatusPlan = tusPlanResumeResponseStatus({ + hasEndpoint: this.options.endpoint != null, + status, + }) + if (responseStatusPlan.action !== 'readOffset') { + if (responseStatusPlan.removeStoredUpload) { await this._removeFromUrlStorage() } - if (!this.options.endpoint) { - // Don't attempt to create a new upload if no endpoint is provided. - throw new DetailedError( - 'tus: unable to resume upload (new upload cannot be created without an endpoint)', - undefined, - req, - res, - ) + if (responseStatusPlan.action === 'fail') { + throw new DetailedError(responseStatusPlan.message, undefined, req, res) } - // Try to create a new upload this.url = null await this._createUpload() return @@ -753,16 +720,21 @@ export class BaseUpload { status, }) if (!offsetResponse.ok && offsetResponse.reason === 'unexpectedStatus') { - throw new DetailedError('tus: unexpected response while resuming upload', undefined, req, res) + throw new DetailedError( + TUS_FLOW_POLICY.messages.unexpectedResumeResponse, + undefined, + req, + res, + ) } if (!offsetResponse.ok && offsetResponse.reason === 'missingOffset') { - throw new DetailedError('tus: missing Upload-Offset header', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.missingResumeOffset, undefined, req, res) } if (!offsetResponse.ok && offsetResponse.reason === 'invalidOffset') { - throw new DetailedError('tus: invalid Upload-Offset header', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.invalidResumeOffset, undefined, req, res) } if (!offsetResponse.ok) { - throw new DetailedError('tus: invalid or missing length value', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.invalidResumeLength, undefined, req, res) } const offset = offsetResponse.offset @@ -777,8 +749,9 @@ export class BaseUpload { // Upload has already been completed and we do not need to send additional // data to the server - if (length != null && offset === length) { - this._emitProgress(length, length) + const completionPlan = tusPlanUploadCompletionAfterOffset({ length, offset }) + if (completionPlan.complete) { + this._emitProgress(completionPlan.length, completionPlan.length) await this._emitSuccess(res) return } @@ -805,7 +778,7 @@ export class BaseUpload { let req: HttpRequest if (this.url == null) { - throw new Error('tus: Expected url to be set') + throw new Error(TUS_FLOW_POLICY.messages.missingPatchUrl) } req = this._openRequest( tusPatchUploadRequestPlan({ @@ -837,10 +810,6 @@ export class BaseUpload { ) } - if (!tusIsSuccessfulResponseStatus(res.getStatus())) { - throw new DetailedError('tus: unexpected response while uploading chunk', undefined, req, res) - } - await this._handleUploadResponse(req, res) } @@ -852,34 +821,30 @@ export class BaseUpload { */ private async _addChunkToRequest(req: HttpRequest): Promise { const start = this._offset - let end = this._offset + this.options.chunkSize + const end = tusChunkEnd({ + chunkSize: this.options.chunkSize, + offset: this._offset, + size: this._size, + uploadLengthDeferred: this._uploadLengthDeferred, + }) req.setProgressHandler((bytesSent) => { this._emitProgress(start + bytesSent, this._size) }) - // The specified chunkSize may be Infinity or the calcluated end position - // may exceed the file's size. In both cases, we limit the end position to - // the input's total size for simpler calculations and correctness. - if ( - // @ts-expect-error _size is set here - (end === Number.POSITIVE_INFINITY || end > this._size) && - !this._uploadLengthDeferred - ) { - // @ts-expect-error _size is set here - end = this._size - } - // TODO: What happens if abort is called during slice? // @ts-expect-error _source is set here const { value, size, done } = await this._source.slice(start, end) const sizeOfValue = size ?? 0 - // If the upload length is deferred, the upload size was not specified during - // upload creation. So, if the file reader is done reading, we know the total - // upload size and can tell the tus server. - if (this._uploadLengthDeferred && done) { - this._size = this._offset + sizeOfValue + const deferredLengthPlan = tusDeferredUploadLengthPlan({ + done, + offset: this._offset, + uploadLengthDeferred: this._uploadLengthDeferred, + valueSize: sizeOfValue, + }) + if (deferredLengthPlan.shouldDeclareLength) { + this._size = deferredLengthPlan.size setRequestHeaders(req, tusUploadLengthHeaders({ size: this._size })) this._uploadLengthDeferred = false } @@ -892,10 +857,14 @@ export class BaseUpload { // in a loop of repeating empty PATCH requests. // See https://community.transloadit.com/t/how-to-abort-hanging-companion-uploads/16488/13 const newSize = this._offset + sizeOfValue - if (!this._uploadLengthDeferred && done && newSize !== this._size) { - throw new Error( - `upload was configured with a size of ${this._size} bytes, but the source is done after ${newSize} bytes`, - ) + const sizeCheck = tusCheckConfiguredUploadSize({ + done, + newSize, + size: this._size, + uploadLengthDeferred: this._uploadLengthDeferred, + }) + if (!sizeCheck.ok) { + throw new Error(sizeCheck.message) } if (value == null) { @@ -918,10 +887,10 @@ export class BaseUpload { status: res.getStatus(), }) if (!chunkResponse.ok && chunkResponse.reason === 'unexpectedStatus') { - throw new DetailedError('tus: unexpected response while uploading chunk', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.unexpectedChunkResponse, undefined, req, res) } if (!chunkResponse.ok) { - throw new DetailedError('tus: invalid or missing offset value', undefined, req, res) + throw new DetailedError(TUS_FLOW_POLICY.messages.invalidChunkOffset, undefined, req, res) } const offset = chunkResponse.offset @@ -930,7 +899,7 @@ export class BaseUpload { this._offset = offset - if (offset === this._size) { + if (tusUploadIsCompleteAfterChunk({ offset, size: this._size })) { // Yay, finally done :) await this._emitSuccess(res) if (this._source) this._source.close() @@ -969,15 +938,17 @@ export class BaseUpload { * @api private */ private async _saveUploadInUrlStorage(): Promise { + const storagePlan = tusPlanUploadStorage({ + fingerprint: this._fingerprint, + hasUrlStorageKey: this._urlStorageKey != null, + storeFingerprintForResuming: this.options.storeFingerprintForResuming, + }) + // We do not store the upload URL // - if it was disabled in the option, or // - if no fingerprint was calculated for the input (i.e. a stream), or - // - if the URL is already stored (i.e. key is set alread). - if ( - !this.options.storeFingerprintForResuming || - !this._fingerprint || - this._urlStorageKey != null - ) { + // - if the URL is already stored (i.e. key is set already). + if (!storagePlan.shouldStore) { return } @@ -985,7 +956,7 @@ export class BaseUpload { size: this._size, metadata: this.options.metadata, creationTime: new Date().toString(), - urlStorageKey: this._fingerprint, + urlStorageKey: storagePlan.fingerprint, } if (this._parallelUploads) { @@ -997,7 +968,10 @@ export class BaseUpload { storedUpload.uploadUrl = this.url } - const urlStorageKey = await this.options.urlStorage.addUpload(this._fingerprint, storedUpload) + const urlStorageKey = await this.options.urlStorage.addUpload( + storagePlan.fingerprint, + storedUpload, + ) // TODO: Emit a waring if urlStorageKey is undefined. Should we even allow this? this._urlStorageKey = urlStorageKey } @@ -1206,7 +1180,7 @@ export async function terminate(url: string, options: UploadOptions): Promise Date: Tue, 26 May 2026 19:53:31 +0200 Subject: [PATCH 015/155] Mark TUS runtime files generated --- lib/NoopUrlStorage.ts | 4 ++++ lib/browser/BrowserFileReader.ts | 4 ++++ lib/browser/FetchHttpStack.ts | 4 ++++ lib/browser/XHRHttpStack.ts | 4 ++++ lib/browser/urlStorage.ts | 4 ++++ lib/commonFileReader.ts | 4 ++++ lib/node/FileUrlStorage.ts | 4 ++++ lib/node/NodeFileReader.ts | 4 ++++ lib/node/NodeHttpStack.ts | 4 ++++ lib/node/sources/NodeStreamFileSource.ts | 4 ++++ lib/node/sources/PathFileSource.ts | 4 ++++ lib/sources/ArrayBufferViewFileSource.ts | 4 ++++ lib/sources/BlobFileSource.ts | 4 ++++ lib/sources/WebStreamFileSource.ts | 4 ++++ lib/upload.ts | 4 ++++ 15 files changed, 60 insertions(+) diff --git a/lib/NoopUrlStorage.ts b/lib/NoopUrlStorage.ts index bd4c0eff6..c995b128f 100644 --- a/lib/NoopUrlStorage.ts +++ b/lib/NoopUrlStorage.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { PreviousUpload, UrlStorage } from './options.js' export class NoopUrlStorage implements UrlStorage { diff --git a/lib/browser/BrowserFileReader.ts b/lib/browser/BrowserFileReader.ts index d7b8ca09d..3ed4af13e 100644 --- a/lib/browser/BrowserFileReader.ts +++ b/lib/browser/BrowserFileReader.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { openFile as openBaseFile, supportedTypes as supportedBaseTypes, diff --git a/lib/browser/FetchHttpStack.ts b/lib/browser/FetchHttpStack.ts index 4d7cf333e..960996bad 100644 --- a/lib/browser/FetchHttpStack.ts +++ b/lib/browser/FetchHttpStack.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { readable as isNodeReadableStream } from 'is-stream' import type { HttpProgressHandler, diff --git a/lib/browser/XHRHttpStack.ts b/lib/browser/XHRHttpStack.ts index 396a371b3..2b272bd0d 100644 --- a/lib/browser/XHRHttpStack.ts +++ b/lib/browser/XHRHttpStack.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { readable as isNodeReadableStream } from 'is-stream' import type { HttpProgressHandler, diff --git a/lib/browser/urlStorage.ts b/lib/browser/urlStorage.ts index e20f76798..ff944d797 100644 --- a/lib/browser/urlStorage.ts +++ b/lib/browser/urlStorage.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { PreviousUpload, UrlStorage } from '../options.js' let hasStorage = false diff --git a/lib/commonFileReader.ts b/lib/commonFileReader.ts index 1ae7883c3..2d9c97ac0 100644 --- a/lib/commonFileReader.ts +++ b/lib/commonFileReader.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { FileSource, UploadInput } from './options.js' import { ArrayBufferViewFileSource } from './sources/ArrayBufferViewFileSource.js' import { BlobFileSource } from './sources/BlobFileSource.js' diff --git a/lib/node/FileUrlStorage.ts b/lib/node/FileUrlStorage.ts index 76e3ed5b4..046a2dc07 100644 --- a/lib/node/FileUrlStorage.ts +++ b/lib/node/FileUrlStorage.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { readFile, writeFile } from 'node:fs/promises' import { lock } from 'proper-lockfile' import type { PreviousUpload, UrlStorage } from '../options.js' diff --git a/lib/node/NodeFileReader.ts b/lib/node/NodeFileReader.ts index c91e5d857..254ab63d2 100644 --- a/lib/node/NodeFileReader.ts +++ b/lib/node/NodeFileReader.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { createReadStream } from 'node:fs' import isStream from 'is-stream' diff --git a/lib/node/NodeHttpStack.ts b/lib/node/NodeHttpStack.ts index 6a5fb0a47..a7215fd71 100644 --- a/lib/node/NodeHttpStack.ts +++ b/lib/node/NodeHttpStack.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import * as http from 'node:http' import * as https from 'node:https' import { Readable, Transform, type Writable } from 'node:stream' diff --git a/lib/node/sources/NodeStreamFileSource.ts b/lib/node/sources/NodeStreamFileSource.ts index 2f4f1b711..ce5e79a35 100644 --- a/lib/node/sources/NodeStreamFileSource.ts +++ b/lib/node/sources/NodeStreamFileSource.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { Readable } from 'node:stream' import type { FileSource } from '../../options.js' diff --git a/lib/node/sources/PathFileSource.ts b/lib/node/sources/PathFileSource.ts index a45f11ca3..489f1365f 100644 --- a/lib/node/sources/PathFileSource.ts +++ b/lib/node/sources/PathFileSource.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { createReadStream, promises as fsPromises, type ReadStream } from 'node:fs' import type { FileSource, PathReference } from '../../options.js' diff --git a/lib/sources/ArrayBufferViewFileSource.ts b/lib/sources/ArrayBufferViewFileSource.ts index c9e7e0c8f..2b67afa52 100644 --- a/lib/sources/ArrayBufferViewFileSource.ts +++ b/lib/sources/ArrayBufferViewFileSource.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { FileSource, SliceResult } from '../options.js' /** diff --git a/lib/sources/BlobFileSource.ts b/lib/sources/BlobFileSource.ts index 8a67dab47..3327e7057 100644 --- a/lib/sources/BlobFileSource.ts +++ b/lib/sources/BlobFileSource.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { isCordova } from '../cordova/isCordova.js' import { readAsByteArray } from '../cordova/readAsByteArray.js' import type { FileSource, SliceResult } from '../options.js' diff --git a/lib/sources/WebStreamFileSource.ts b/lib/sources/WebStreamFileSource.ts index 5d1f64343..6ae4dcadc 100644 --- a/lib/sources/WebStreamFileSource.ts +++ b/lib/sources/WebStreamFileSource.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { FileSource, SliceResult } from '../options.js' function len(blobOrArray: WebStreamFileSource['_buffer']): number { diff --git a/lib/upload.ts b/lib/upload.ts index 81ce98eb9..7fef44f1e 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { Base64 } from 'js-base64' // TODO: Package url-parse is CommonJS. Can we replace this with a ESM package that // provides WHATWG URL? Then we can get rid of @rollup/plugin-commonjs. From e5c62ce405dcd3afa18771b8b816628c53f09c35 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 20:10:09 +0200 Subject: [PATCH 016/155] Mark remaining source files generated --- lib/DetailedError.ts | 4 ++++ lib/browser/fileSignature.ts | 4 ++++ lib/browser/index.ts | 4 ++++ lib/cordova/isCordova.ts | 4 ++++ lib/cordova/readAsByteArray.ts | 4 ++++ lib/logger.ts | 4 ++++ lib/node/fileSignature.ts | 4 ++++ lib/node/index.ts | 4 ++++ lib/options.ts | 12 ++++-------- lib/reactnative/isReactNative.ts | 4 ++++ lib/reactnative/uriToBlob.ts | 4 ++++ lib/uuid.ts | 4 ++++ 12 files changed, 48 insertions(+), 8 deletions(-) diff --git a/lib/DetailedError.ts b/lib/DetailedError.ts index 93ffd8f50..10e1627d9 100644 --- a/lib/DetailedError.ts +++ b/lib/DetailedError.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { HttpRequest, HttpResponse } from './options.js' export class DetailedError extends Error { diff --git a/lib/browser/fileSignature.ts b/lib/browser/fileSignature.ts index 276b8f5f6..0181aa9f7 100644 --- a/lib/browser/fileSignature.ts +++ b/lib/browser/fileSignature.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { ReactNativeFile, UploadInput, UploadOptions } from '../options.js' import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js' diff --git a/lib/browser/index.ts b/lib/browser/index.ts index 3bde570d6..adea45dd9 100644 --- a/lib/browser/index.ts +++ b/lib/browser/index.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { DetailedError } from '../DetailedError.js' import { enableDebugLog } from '../logger.js' import { NoopUrlStorage } from '../NoopUrlStorage.js' diff --git a/lib/cordova/isCordova.ts b/lib/cordova/isCordova.ts index abe23839b..11a5d3475 100644 --- a/lib/cordova/isCordova.ts +++ b/lib/cordova/isCordova.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + const isCordova = () => typeof window !== 'undefined' && ('PhoneGap' in window || 'Cordova' in window || 'cordova' in window) diff --git a/lib/cordova/readAsByteArray.ts b/lib/cordova/readAsByteArray.ts index 9f7e33832..75915d44f 100644 --- a/lib/cordova/readAsByteArray.ts +++ b/lib/cordova/readAsByteArray.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + /** * readAsByteArray converts a File/Blob object to a Uint8Array. * This function is only used on the Apache Cordova platform. diff --git a/lib/logger.ts b/lib/logger.ts index 8e1e6ac88..6d92aa19e 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + let isEnabled = false // TODO: Replace this global state with an option for the Upload class diff --git a/lib/node/fileSignature.ts b/lib/node/fileSignature.ts index 8dff27dec..9816359f7 100644 --- a/lib/node/fileSignature.ts +++ b/lib/node/fileSignature.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { createHash } from 'node:crypto' import { ReadStream } from 'node:fs' import { stat } from 'node:fs/promises' diff --git a/lib/node/index.ts b/lib/node/index.ts index d39e5427b..f969fc606 100644 --- a/lib/node/index.ts +++ b/lib/node/index.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import { DetailedError } from '../DetailedError.js' import { enableDebugLog } from '../logger.js' import { NoopUrlStorage } from '../NoopUrlStorage.js' diff --git a/lib/options.ts b/lib/options.ts index 9798c6d84..fe166028c 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -1,18 +1,14 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { Readable as NodeReadableStream } from 'node:stream' import type { DetailedError } from './DetailedError.js' -// - -// This block is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this block by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - export const PROTOCOL_TUS_V1 = 'tus-v1' export const PROTOCOL_IETF_DRAFT_03 = 'ietf-draft-03' export const PROTOCOL_IETF_DRAFT_05 = 'ietf-draft-05' -// - /** * ReactNativeFile describes the structure that is returned from the * Expo image picker (see https://docs.expo.dev/versions/latest/sdk/imagepicker/) diff --git a/lib/reactnative/isReactNative.ts b/lib/reactnative/isReactNative.ts index bc97284ca..ade2abb9d 100644 --- a/lib/reactnative/isReactNative.ts +++ b/lib/reactnative/isReactNative.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + import type { ReactNativeFile } from '../options.js' export function isReactNativePlatform() { diff --git a/lib/reactnative/uriToBlob.ts b/lib/reactnative/uriToBlob.ts index c392e35fc..417e8decd 100644 --- a/lib/reactnative/uriToBlob.ts +++ b/lib/reactnative/uriToBlob.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + /** * uriToBlob resolves a URI to a Blob object. This is used for * React Native to retrieve a file (identified by a file:// diff --git a/lib/uuid.ts b/lib/uuid.ts index a1fc25c71..8b43c7a58 100644 --- a/lib/uuid.ts +++ b/lib/uuid.ts @@ -1,3 +1,7 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + /** * Generate a UUID v4 based on random numbers. We intentioanlly use the less * secure Math.random function here since the more secure crypto.getRandomNumbers From a5debf96a798adc44dc6f0bc78f75352fff2fef8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 21:18:21 +0200 Subject: [PATCH 017/155] Keep generated boundary to protocol helpers --- lib/DetailedError.ts | 4 ---- lib/NoopUrlStorage.ts | 4 ---- lib/browser/BrowserFileReader.ts | 4 ---- lib/browser/FetchHttpStack.ts | 4 ---- lib/browser/XHRHttpStack.ts | 4 ---- lib/browser/fileSignature.ts | 4 ---- lib/browser/index.ts | 4 ---- lib/browser/urlStorage.ts | 4 ---- lib/commonFileReader.ts | 4 ---- lib/cordova/isCordova.ts | 4 ---- lib/cordova/readAsByteArray.ts | 4 ---- lib/logger.ts | 4 ---- lib/node/FileUrlStorage.ts | 4 ---- lib/node/NodeFileReader.ts | 4 ---- lib/node/NodeHttpStack.ts | 4 ---- lib/node/fileSignature.ts | 4 ---- lib/node/index.ts | 4 ---- lib/node/sources/NodeStreamFileSource.ts | 4 ---- lib/node/sources/PathFileSource.ts | 4 ---- lib/options.ts | 4 ---- lib/reactnative/isReactNative.ts | 4 ---- lib/reactnative/uriToBlob.ts | 4 ---- lib/sources/ArrayBufferViewFileSource.ts | 4 ---- lib/sources/BlobFileSource.ts | 4 ---- lib/sources/WebStreamFileSource.ts | 4 ---- lib/upload.ts | 4 ---- lib/uuid.ts | 4 ---- 27 files changed, 108 deletions(-) diff --git a/lib/DetailedError.ts b/lib/DetailedError.ts index 10e1627d9..93ffd8f50 100644 --- a/lib/DetailedError.ts +++ b/lib/DetailedError.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { HttpRequest, HttpResponse } from './options.js' export class DetailedError extends Error { diff --git a/lib/NoopUrlStorage.ts b/lib/NoopUrlStorage.ts index c995b128f..bd4c0eff6 100644 --- a/lib/NoopUrlStorage.ts +++ b/lib/NoopUrlStorage.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { PreviousUpload, UrlStorage } from './options.js' export class NoopUrlStorage implements UrlStorage { diff --git a/lib/browser/BrowserFileReader.ts b/lib/browser/BrowserFileReader.ts index 3ed4af13e..d7b8ca09d 100644 --- a/lib/browser/BrowserFileReader.ts +++ b/lib/browser/BrowserFileReader.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { openFile as openBaseFile, supportedTypes as supportedBaseTypes, diff --git a/lib/browser/FetchHttpStack.ts b/lib/browser/FetchHttpStack.ts index 960996bad..4d7cf333e 100644 --- a/lib/browser/FetchHttpStack.ts +++ b/lib/browser/FetchHttpStack.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { readable as isNodeReadableStream } from 'is-stream' import type { HttpProgressHandler, diff --git a/lib/browser/XHRHttpStack.ts b/lib/browser/XHRHttpStack.ts index 2b272bd0d..396a371b3 100644 --- a/lib/browser/XHRHttpStack.ts +++ b/lib/browser/XHRHttpStack.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { readable as isNodeReadableStream } from 'is-stream' import type { HttpProgressHandler, diff --git a/lib/browser/fileSignature.ts b/lib/browser/fileSignature.ts index 0181aa9f7..276b8f5f6 100644 --- a/lib/browser/fileSignature.ts +++ b/lib/browser/fileSignature.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { ReactNativeFile, UploadInput, UploadOptions } from '../options.js' import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js' diff --git a/lib/browser/index.ts b/lib/browser/index.ts index adea45dd9..3bde570d6 100644 --- a/lib/browser/index.ts +++ b/lib/browser/index.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { DetailedError } from '../DetailedError.js' import { enableDebugLog } from '../logger.js' import { NoopUrlStorage } from '../NoopUrlStorage.js' diff --git a/lib/browser/urlStorage.ts b/lib/browser/urlStorage.ts index ff944d797..e20f76798 100644 --- a/lib/browser/urlStorage.ts +++ b/lib/browser/urlStorage.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { PreviousUpload, UrlStorage } from '../options.js' let hasStorage = false diff --git a/lib/commonFileReader.ts b/lib/commonFileReader.ts index 2d9c97ac0..1ae7883c3 100644 --- a/lib/commonFileReader.ts +++ b/lib/commonFileReader.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { FileSource, UploadInput } from './options.js' import { ArrayBufferViewFileSource } from './sources/ArrayBufferViewFileSource.js' import { BlobFileSource } from './sources/BlobFileSource.js' diff --git a/lib/cordova/isCordova.ts b/lib/cordova/isCordova.ts index 11a5d3475..abe23839b 100644 --- a/lib/cordova/isCordova.ts +++ b/lib/cordova/isCordova.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - const isCordova = () => typeof window !== 'undefined' && ('PhoneGap' in window || 'Cordova' in window || 'cordova' in window) diff --git a/lib/cordova/readAsByteArray.ts b/lib/cordova/readAsByteArray.ts index 75915d44f..9f7e33832 100644 --- a/lib/cordova/readAsByteArray.ts +++ b/lib/cordova/readAsByteArray.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - /** * readAsByteArray converts a File/Blob object to a Uint8Array. * This function is only used on the Apache Cordova platform. diff --git a/lib/logger.ts b/lib/logger.ts index 6d92aa19e..8e1e6ac88 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - let isEnabled = false // TODO: Replace this global state with an option for the Upload class diff --git a/lib/node/FileUrlStorage.ts b/lib/node/FileUrlStorage.ts index 046a2dc07..76e3ed5b4 100644 --- a/lib/node/FileUrlStorage.ts +++ b/lib/node/FileUrlStorage.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { readFile, writeFile } from 'node:fs/promises' import { lock } from 'proper-lockfile' import type { PreviousUpload, UrlStorage } from '../options.js' diff --git a/lib/node/NodeFileReader.ts b/lib/node/NodeFileReader.ts index 254ab63d2..c91e5d857 100644 --- a/lib/node/NodeFileReader.ts +++ b/lib/node/NodeFileReader.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { createReadStream } from 'node:fs' import isStream from 'is-stream' diff --git a/lib/node/NodeHttpStack.ts b/lib/node/NodeHttpStack.ts index a7215fd71..6a5fb0a47 100644 --- a/lib/node/NodeHttpStack.ts +++ b/lib/node/NodeHttpStack.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import * as http from 'node:http' import * as https from 'node:https' import { Readable, Transform, type Writable } from 'node:stream' diff --git a/lib/node/fileSignature.ts b/lib/node/fileSignature.ts index 9816359f7..8dff27dec 100644 --- a/lib/node/fileSignature.ts +++ b/lib/node/fileSignature.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { createHash } from 'node:crypto' import { ReadStream } from 'node:fs' import { stat } from 'node:fs/promises' diff --git a/lib/node/index.ts b/lib/node/index.ts index f969fc606..d39e5427b 100644 --- a/lib/node/index.ts +++ b/lib/node/index.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { DetailedError } from '../DetailedError.js' import { enableDebugLog } from '../logger.js' import { NoopUrlStorage } from '../NoopUrlStorage.js' diff --git a/lib/node/sources/NodeStreamFileSource.ts b/lib/node/sources/NodeStreamFileSource.ts index ce5e79a35..2f4f1b711 100644 --- a/lib/node/sources/NodeStreamFileSource.ts +++ b/lib/node/sources/NodeStreamFileSource.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { Readable } from 'node:stream' import type { FileSource } from '../../options.js' diff --git a/lib/node/sources/PathFileSource.ts b/lib/node/sources/PathFileSource.ts index 489f1365f..a45f11ca3 100644 --- a/lib/node/sources/PathFileSource.ts +++ b/lib/node/sources/PathFileSource.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { createReadStream, promises as fsPromises, type ReadStream } from 'node:fs' import type { FileSource, PathReference } from '../../options.js' diff --git a/lib/options.ts b/lib/options.ts index fe166028c..53a1e284a 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { Readable as NodeReadableStream } from 'node:stream' import type { DetailedError } from './DetailedError.js' diff --git a/lib/reactnative/isReactNative.ts b/lib/reactnative/isReactNative.ts index ade2abb9d..bc97284ca 100644 --- a/lib/reactnative/isReactNative.ts +++ b/lib/reactnative/isReactNative.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { ReactNativeFile } from '../options.js' export function isReactNativePlatform() { diff --git a/lib/reactnative/uriToBlob.ts b/lib/reactnative/uriToBlob.ts index 417e8decd..c392e35fc 100644 --- a/lib/reactnative/uriToBlob.ts +++ b/lib/reactnative/uriToBlob.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - /** * uriToBlob resolves a URI to a Blob object. This is used for * React Native to retrieve a file (identified by a file:// diff --git a/lib/sources/ArrayBufferViewFileSource.ts b/lib/sources/ArrayBufferViewFileSource.ts index 2b67afa52..c9e7e0c8f 100644 --- a/lib/sources/ArrayBufferViewFileSource.ts +++ b/lib/sources/ArrayBufferViewFileSource.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { FileSource, SliceResult } from '../options.js' /** diff --git a/lib/sources/BlobFileSource.ts b/lib/sources/BlobFileSource.ts index 3327e7057..8a67dab47 100644 --- a/lib/sources/BlobFileSource.ts +++ b/lib/sources/BlobFileSource.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { isCordova } from '../cordova/isCordova.js' import { readAsByteArray } from '../cordova/readAsByteArray.js' import type { FileSource, SliceResult } from '../options.js' diff --git a/lib/sources/WebStreamFileSource.ts b/lib/sources/WebStreamFileSource.ts index 6ae4dcadc..5d1f64343 100644 --- a/lib/sources/WebStreamFileSource.ts +++ b/lib/sources/WebStreamFileSource.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import type { FileSource, SliceResult } from '../options.js' function len(blobOrArray: WebStreamFileSource['_buffer']): number { diff --git a/lib/upload.ts b/lib/upload.ts index 7fef44f1e..81ce98eb9 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - import { Base64 } from 'js-base64' // TODO: Package url-parse is CommonJS. Can we replace this with a ESM package that // provides WHATWG URL? Then we can get rid of @rollup/plugin-commonjs. diff --git a/lib/uuid.ts b/lib/uuid.ts index 8b43c7a58..a1fc25c71 100644 --- a/lib/uuid.ts +++ b/lib/uuid.ts @@ -1,7 +1,3 @@ -// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, -// please report the issue instead of editing this file by hand; the source fix -// belongs in the protocol contract generator so all TUS clients stay in sync. - /** * Generate a UUID v4 based on random numbers. We intentioanlly use the less * secure Math.random function here since the more secure crypto.getRandomNumbers From 66f66a886ea3224cb94537613e47e436250c6602 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 22:12:27 +0200 Subject: [PATCH 018/155] Regenerate TUS feature contract fixture --- test/spec/generated-protocol-contract.js | 387 ++++++++++++++++++++++- 1 file changed, 386 insertions(+), 1 deletion(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 28632fea8..a255e14d4 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -348,7 +348,29 @@ export const tusProtocolOperations = [ export const tusClientFeatures = [ { + conformance: { + scenarioIds: ['singleUploadLifecycle'], + status: 'covered-by-generated-scenario', + }, + description: 'Create an upload, store its URL, upload bytes, and finish successfully.', featureId: 'singleUploadLifecycle', + flow: [ + { + kind: 'primitive', + primitive: 'open-input-source', + summary: 'Open the caller input as a sliceable source.', + }, + { + kind: 'operation', + operationId: 'createTusUpload', + summary: 'Create the remote upload resource.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Upload bytes until the accepted offset reaches the known length.', + }, + ], operationIds: ['createTusUpload', 'getTusUploadOffset', 'patchTusUpload'], primitives: [ 'open-input-source', @@ -360,40 +382,403 @@ export const tusClientFeatures = [ ], }, { + conformance: { + scenarioIds: ['resumeFromPreviousUpload'], + status: 'covered-by-generated-scenario', + }, + description: 'Resume a stored upload URL by discovering the remote offset before patching.', featureId: 'resumeUpload', + flow: [ + { + kind: 'primitive', + primitive: 'resume-from-previous-upload', + summary: 'Load a stored upload URL selected by fingerprint.', + }, + { + kind: 'operation', + operationId: 'getTusUploadOffset', + summary: 'Read the server offset for the stored upload URL.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Continue uploading from the discovered offset.', + }, + ], operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], }, { + conformance: { + scenarioIds: ['deferredLengthUpload'], + status: 'covered-by-generated-scenario', + }, + description: 'Create an upload without a known length and declare the length on final PATCH.', featureId: 'deferredLengthUpload', + flow: [ + { + kind: 'operation', + operationId: 'createTusUpload', + summary: 'Create the upload with deferred length.', + }, + { + kind: 'primitive', + primitive: 'defer-upload-length', + summary: 'Track the source until the final chunk reveals the total size.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Declare Upload-Length on the final chunk request.', + }, + ], operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['defer-upload-length', 'emit-progress'], }, { + conformance: { + scenarioIds: ['creationWithUpload'], + status: 'covered-by-generated-scenario', + }, + description: 'Send the first bytes on the creation request when the server/client support it.', featureId: 'creationWithUpload', + flow: [ + { + kind: 'operation', + operationId: 'createTusUpload', + summary: 'Create the upload while streaming the initial body.', + }, + { + kind: 'primitive', + primitive: 'upload-during-creation', + summary: 'Interpret the creation response as an accepted offset.', + }, + ], operationIds: ['createTusUpload'], primitives: ['upload-during-creation', 'emit-progress'], }, { + conformance: { + scenarioIds: ['overridePatchMethod'], + status: 'covered-by-generated-scenario', + }, + description: 'Tunnel PATCH through POST with the method-override header.', featureId: 'overridePatchMethod', + flow: [ + { + kind: 'operation', + operationId: 'getTusUploadOffset', + summary: 'Resume from the upload URL before sending bytes.', + }, + { + kind: 'primitive', + primitive: 'override-patch-method', + summary: 'Replace PATCH with POST while preserving the protocol operation intent.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Upload bytes through the overridden request.', + }, + ], operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['override-patch-method'], }, { + conformance: { + scenarioIds: ['parallelUploadConcat'], + status: 'covered-by-generated-scenario', + }, + description: 'Split one input into partial uploads and concatenate their upload URLs.', featureId: 'parallelUploadConcat', + flow: [ + { + kind: 'primitive', + primitive: 'split-parallel-upload-boundaries', + summary: 'Split the input into stable byte ranges.', + }, + { + kind: 'operation', + operationId: 'createTusUpload', + summary: 'Create partial uploads for each range.', + }, + { + kind: 'primitive', + primitive: 'concatenate-partial-uploads', + summary: 'Create the final upload from completed partial upload URLs.', + }, + ], operationIds: ['createTusUpload', 'patchTusUpload'], - primitives: ['concatenate-partial-uploads', 'emit-progress'], + primitives: [ + 'concatenate-partial-uploads', + 'emit-progress', + 'split-parallel-upload-boundaries', + ], }, { + conformance: { + scenarioIds: ['retryPatchAfterOffsetRecovery'], + status: 'covered-by-generated-scenario', + }, + description: 'Recover from a failed chunk by reading the server offset before retrying.', featureId: 'retryOffsetRecovery', + flow: [ + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Attempt the chunk upload.', + }, + { + kind: 'primitive', + primitive: 'recover-offset-after-error', + summary: 'Discover the accepted offset after a retryable failure.', + }, + { + kind: 'operation', + operationId: 'getTusUploadOffset', + summary: 'Use HEAD to recover the offset before retrying PATCH.', + }, + ], operationIds: ['createTusUpload', 'getTusUploadOffset', 'patchTusUpload'], primitives: ['retry-with-backoff', 'recover-offset-after-error'], }, { + conformance: { + scenarioIds: ['terminateWithRetry'], + status: 'covered-by-generated-scenario', + }, + description: 'Terminate an upload resource and retry retryable termination failures.', featureId: 'terminateUpload', + flow: [ + { + kind: 'primitive', + primitive: 'terminate-upload', + summary: 'Choose server-side termination for an upload URL.', + }, + { + kind: 'operation', + operationId: 'terminateTusUpload', + summary: 'Delete the upload resource.', + }, + ], operationIds: ['terminateTusUpload'], primitives: ['terminate-upload', 'retry-with-backoff'], }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Abort the active request, pending retry timer, and any partial uploads.', + featureId: 'abortUpload', + flow: [ + { + kind: 'primitive', + primitive: 'abort-current-request', + summary: 'Cancel in-flight transport work without emitting user callbacks after abort.', + }, + ], + operationIds: [], + primitives: ['abort-current-request'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Expose progress and accepted-chunk callbacks from runtime upload activity.', + featureId: 'uploadCallbacks', + flow: [ + { + kind: 'primitive', + primitive: 'emit-progress', + summary: 'Report bytes sent against known or deferred length.', + }, + { + kind: 'primitive', + primitive: 'emit-chunk-complete', + summary: 'Report chunk size, accepted offset, and total size after server acceptance.', + }, + { + kind: 'primitive', + primitive: 'emit-upload-url', + summary: 'Notify once a usable upload URL is known.', + }, + ], + operationIds: [], + primitives: ['emit-progress', 'emit-chunk-complete', 'emit-upload-url'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Run before-request, after-response, and custom retry hooks around transport.', + featureId: 'requestLifecycleHooks', + flow: [ + { + kind: 'primitive', + primitive: 'run-request-hooks', + summary: 'Call user hooks around each HTTP request/response pair.', + }, + { + kind: 'primitive', + primitive: 'customize-retry', + summary: 'Let user retry policy override default retry decisions.', + }, + ], + operationIds: [], + primitives: ['customize-retry', 'run-request-hooks'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Persist, find, resume, and optionally remove upload URLs by fingerprint.', + featureId: 'resumeUrlStorage', + flow: [ + { + kind: 'primitive', + primitive: 'fingerprint-input', + summary: 'Derive a stable key for the input when possible.', + }, + { + kind: 'primitive', + primitive: 'store-resume-url', + summary: 'Persist upload URLs and partial-upload URLs for future resumption.', + }, + { + kind: 'primitive', + primitive: 'remove-stored-url-on-success', + summary: 'Remove stored upload URLs when configured after success or invalidation.', + }, + ], + operationIds: [], + primitives: ['fingerprint-input', 'store-resume-url', 'remove-stored-url-on-success'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Support the reference client input/source families across runtimes.', + featureId: 'inputSources', + flow: [ + { + kind: 'primitive', + primitive: 'read-browser-file', + summary: 'Read browser Blob/File and ArrayBuffer-family inputs.', + }, + { + kind: 'primitive', + primitive: 'read-node-stream', + summary: 'Read Node streams when size and chunk constraints are satisfied.', + }, + { + kind: 'primitive', + primitive: 'read-web-stream', + summary: 'Read Web Streams with deferred or configured size.', + }, + { + kind: 'primitive', + primitive: 'read-node-file', + summary: 'Read filesystem paths and fs streams, including parallel ranges.', + }, + ], + operationIds: [], + primitives: ['read-browser-file', 'read-node-file', 'read-node-stream', 'read-web-stream'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Support browser and file-backed URL storage implementations.', + featureId: 'urlStorageBackends', + flow: [ + { + kind: 'primitive', + primitive: 'store-browser-url', + summary: 'Persist upload records in browser localStorage.', + }, + { + kind: 'primitive', + primitive: 'store-file-url', + summary: 'Persist upload records in the Node file store.', + }, + ], + operationIds: [], + primitives: ['store-browser-url', 'store-file-url'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Select between tus v1 and supported IETF draft client protocol modes.', + featureId: 'protocolVersionSelection', + flow: [ + { + kind: 'primitive', + primitive: 'select-client-protocol', + summary: 'Choose request headers and response expectations for the selected protocol.', + }, + ], + operationIds: ['createTusUpload', 'getTusUploadOffset', 'patchTusUpload'], + primitives: ['select-client-protocol'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Normalize relative Location headers against the request endpoint.', + featureId: 'relativeLocationResolution', + flow: [ + { + kind: 'primitive', + primitive: 'resolve-relative-location', + summary: 'Resolve server Location headers with the creation endpoint as origin.', + }, + ], + operationIds: ['createTusUpload'], + primitives: ['resolve-relative-location'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Validate option combinations before starting runtime work.', + featureId: 'startOptionValidation', + flow: [ + { + kind: 'primitive', + primitive: 'validate-start-options', + summary: 'Reject missing inputs and incompatible parallel/deferred/resume options.', + }, + ], + operationIds: [], + primitives: ['validate-start-options'], + }, + { + conformance: { + scenarioIds: [], + status: 'needs-generated-scenario', + }, + description: 'Attach request, response, status, body, and request ID context to errors.', + featureId: 'detailedErrors', + flow: [ + { + kind: 'primitive', + primitive: 'report-detailed-errors', + summary: 'Return user-facing errors with enough transport context for debugging.', + }, + ], + operationIds: [], + primitives: ['report-detailed-errors'], + }, ] export const tusClientConformanceScenarios = [ From 603f7a0ca123aaff53a7053ce54d6cad44a6743f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 22:18:17 +0200 Subject: [PATCH 019/155] Install Puppeteer browser in CI --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b55dcc41f..4562f253e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: deno-version: latest - name: Install dependencies run: npm ci + - name: Install Puppeteer browser + if: matrix.suite == 'puppeteer' + run: npx puppeteer browsers install chrome - name: Build run: npm run build - name: Test From 1371d909e9c721f654d7eeb11922e70616d7c21e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 11:35:33 +0200 Subject: [PATCH 020/155] Regenerate upload body protocol fixtures --- lib/protocol_generated.ts | 14 ++-- test/spec/generated-protocol-contract.js | 72 +++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 1 + 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index d34f6ac24..1caefb558 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -109,7 +109,7 @@ export const TUS_PROTOCOL_REQUEST_HEADERS: Record }, } -export const TUS_PROTOCOL_CHUNK_CONTENT_TYPES: Record = { +export const TUS_PROTOCOL_UPLOAD_BODY_CONTENT_TYPES: Record = { 'ietf-draft-05': 'application/partial-upload', 'tus-v1': 'application/offset+octet-stream', } @@ -164,6 +164,10 @@ export const TUS_RETRY_POLICY = { successStatusCategory: 200, } +export const TUS_UPLOAD_BODY = { + contentTypeHeaderName: 'Content-Type', +} + export const TUS_FLOW_POLICY = { messages: { configuredUploadSizeMismatch: @@ -684,8 +688,8 @@ export function tusSupportsProtocol(protocol: string): boolean { return TUS_SUPPORTED_PROTOCOLS.includes(protocol) } -export function tusChunkContentTypeForProtocol(protocol: string): string | undefined { - return TUS_PROTOCOL_CHUNK_CONTENT_TYPES[protocol] +export function tusUploadBodyContentTypeForProtocol(protocol: string): string | undefined { + return TUS_PROTOCOL_UPLOAD_BODY_CONTENT_TYPES[protocol] } export function tusUploadCompleteHeaderForProtocol( @@ -815,10 +819,10 @@ export function tusUploadBodyHeaders({ done: boolean protocol: string }): Record { - const contentType = tusChunkContentTypeForProtocol(protocol) + const contentType = tusUploadBodyContentTypeForProtocol(protocol) return { - ...(contentType ? { [TUS_HEADERS.CONTENT_TYPE]: contentType } : {}), + ...(contentType ? { [TUS_UPLOAD_BODY.contentTypeHeaderName]: contentType } : {}), ...tusUploadCompleteHeaders({ done, protocol }), } } diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index a255e14d4..b56af9dcf 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -457,6 +457,29 @@ export const tusClientFeatures = [ operationIds: ['createTusUpload'], primitives: ['upload-during-creation', 'emit-progress'], }, + { + conformance: { + scenarioIds: ['uploadBodyHeaders'], + status: 'covered-by-generated-scenario', + }, + description: + 'Send protocol-specific upload body headers whenever the client transmits file bytes.', + featureId: 'uploadBodyHeaders', + flow: [ + { + kind: 'primitive', + primitive: 'send-upload-body-headers', + summary: 'Attach the protocol-specific upload body content type when a request has bytes.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Upload bytes with the protocol-specific body headers.', + }, + ], + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['send-upload-body-headers'], + }, { conformance: { scenarioIds: ['overridePatchMethod'], @@ -875,6 +898,55 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'creationWithUpload', }, + { + behavior: 'upload-body-headers', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', + }, + featureId: 'uploadBodyHeaders', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['send-upload-body-headers'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/upload-body-headers-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'uploadBodyHeaders', + }, { behavior: 'resume-from-previous-upload', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3ec4fd98d..9908c8bf2 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -315,6 +315,7 @@ describe('generated TUS protocol contract', () => { expect(tusClientConformanceScenarios.map((scenario) => scenario.scenarioId)).toEqual([ 'singleUploadLifecycle', 'creationWithUpload', + 'uploadBodyHeaders', 'resumeFromPreviousUpload', 'deferredLengthUpload', 'overridePatchMethod', From bd749acca13617217d6deffa8c481ffe74e8f3a0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 12:10:00 +0200 Subject: [PATCH 021/155] Use generated TUS chunk response plan --- lib/protocol_generated.ts | 42 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 28 +++++++++++++------------- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 1caefb558..d1614e1ac 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -287,6 +287,15 @@ export type TusUploadStoragePlan = | { shouldStore: false } | { fingerprint: string; shouldStore: true } +export type TusUploadChunkResponsePlan = + | { action: 'complete'; chunkSize: number; offset: number } + | { action: 'continue'; chunkSize: number; offset: number } + | { + action: 'fail' + message: string + reason: 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' + } + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -521,6 +530,39 @@ export function tusUploadIsCompleteAfterChunk({ return offset === size } +export function tusPlanUploadChunkResponse({ + currentOffset, + response, + size, +}: { + currentOffset: number + response: TusUploadChunkResponseReadResult + size: number | null +}): TusUploadChunkResponsePlan { + if (!response.ok && response.reason === 'unexpectedStatus') { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.unexpectedChunkResponse, + reason: response.reason, + } + } + + if (!response.ok) { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.invalidChunkOffset, + reason: response.reason, + } + } + + const chunkSize = response.offset - currentOffset + if (tusUploadIsCompleteAfterChunk({ offset: response.offset, size })) { + return { action: 'complete', chunkSize, offset: response.offset } + } + + return { action: 'continue', chunkSize, offset: response.offset } +} + export function tusShouldResetRetryAttempt({ offset, offsetBeforeRetry, diff --git a/lib/upload.ts b/lib/upload.ts index 81ce98eb9..15a2f37ec 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -30,6 +30,7 @@ import { tusPatchUploadRequestPlan, tusPlanResumeResponseStatus, tusPlanSingleUploadStart, + tusPlanUploadChunkResponse, tusPlanUploadCompletionAfterOffset, tusPlanUploadStorage, tusReadUploadChunkResponse, @@ -41,7 +42,6 @@ import { tusShouldSendUploadBodyDuringCreation, tusTerminateUploadRequestPlan, tusUploadBodyHeaders, - tusUploadIsCompleteAfterChunk, tusUploadLengthHeaders, tusValidateCreateUpload, tusValidateUploadStart, @@ -882,24 +882,24 @@ export class BaseUpload { * @api private */ private async _handleUploadResponse(req: HttpRequest, res: HttpResponse): Promise { - const chunkResponse = tusReadUploadChunkResponse({ - getHeader: (headerName) => res.getHeader(headerName), - status: res.getStatus(), + const chunkResponsePlan = tusPlanUploadChunkResponse({ + currentOffset: this._offset, + response: tusReadUploadChunkResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }), + size: this._size, }) - if (!chunkResponse.ok && chunkResponse.reason === 'unexpectedStatus') { - throw new DetailedError(TUS_FLOW_POLICY.messages.unexpectedChunkResponse, undefined, req, res) - } - if (!chunkResponse.ok) { - throw new DetailedError(TUS_FLOW_POLICY.messages.invalidChunkOffset, undefined, req, res) + if (chunkResponsePlan.action === 'fail') { + throw new DetailedError(chunkResponsePlan.message, undefined, req, res) } - const offset = chunkResponse.offset - this._emitProgress(offset, this._size) - this._emitChunkComplete(offset - this._offset, offset, this._size) + this._emitProgress(chunkResponsePlan.offset, this._size) + this._emitChunkComplete(chunkResponsePlan.chunkSize, chunkResponsePlan.offset, this._size) - this._offset = offset + this._offset = chunkResponsePlan.offset - if (tusUploadIsCompleteAfterChunk({ offset, size: this._size })) { + if (chunkResponsePlan.action === 'complete') { // Yay, finally done :) await this._emitSuccess(res) if (this._source) this._source.close() From e03379af4284a6519e4efee6420b824953a1dafc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 12:28:25 +0200 Subject: [PATCH 022/155] Use generated TUS creation response plan --- lib/protocol_generated.ts | 39 +++++++++++++++++++++++++++++ lib/upload.ts | 52 +++++++++++++++++---------------------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index d1614e1ac..e040026e8 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -287,6 +287,13 @@ export type TusUploadStoragePlan = | { shouldStore: false } | { fingerprint: string; shouldStore: true } +export type TusUploadCreationFollowUp = 'none' | 'patchIfNonempty' + +export type TusUploadCreationResponsePlan = + | { action: 'complete'; location: string } + | { action: 'continue'; location: string } + | { action: 'fail'; message: string; reason: 'missingLocation' | 'unexpectedStatus' } + export type TusUploadChunkResponsePlan = | { action: 'complete'; chunkSize: number; offset: number } | { action: 'continue'; chunkSize: number; offset: number } @@ -463,6 +470,38 @@ export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | return size === 0 } +export function tusPlanUploadCreationResponse({ + followUp, + response, + size, +}: { + followUp: TusUploadCreationFollowUp + response: TusUploadCreationResponseReadResult + size: number | null +}): TusUploadCreationResponsePlan { + if (!response.ok && response.reason === 'unexpectedStatus') { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.unexpectedCreateResponse, + reason: response.reason, + } + } + + if (!response.ok) { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.uploadLocationMissing, + reason: response.reason, + } + } + + if (followUp === 'none' || tusCreatedUploadCompletesWithoutPatch({ size })) { + return { action: 'complete', location: response.location } + } + + return { action: 'continue', location: response.location } +} + export function tusPlanResumeResponseStatus({ hasEndpoint, status, diff --git a/lib/upload.ts b/lib/upload.ts index 15a2f37ec..d5b99db2f 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -19,7 +19,6 @@ import { type TusRequestPlan, tusCheckConfiguredUploadSize, tusChunkEnd, - tusCreatedUploadCompletesWithoutPatch, tusCreateUploadCompleteValue, tusCreateUploadRequestPlan, tusDeferredUploadLengthPlan, @@ -32,6 +31,7 @@ import { tusPlanSingleUploadStart, tusPlanUploadChunkResponse, tusPlanUploadCompletionAfterOffset, + tusPlanUploadCreationResponse, tusPlanUploadStorage, tusReadUploadChunkResponse, tusReadUploadCreationResponse, @@ -382,23 +382,19 @@ export class BaseUpload { throw new DetailedError('tus: failed to concatenate parallel uploads', err, req, undefined) } - const creationResponse = tusReadUploadCreationResponse({ - getHeader: (headerName) => res.getHeader(headerName), - status: res.getStatus(), + const creationResponsePlan = tusPlanUploadCreationResponse({ + followUp: 'none', + response: tusReadUploadCreationResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }), + size: this._size, }) - if (!creationResponse.ok && creationResponse.reason === 'unexpectedStatus') { - throw new DetailedError( - TUS_FLOW_POLICY.messages.unexpectedCreateResponse, - undefined, - req, - res, - ) - } - if (!creationResponse.ok) { - throw new DetailedError(TUS_FLOW_POLICY.messages.uploadLocationMissing, undefined, req, res) + if (creationResponsePlan.action === 'fail') { + throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(endpoint, creationResponse.location) + this.url = resolveUrl(endpoint, creationResponsePlan.location) log(`Created upload at ${this.url}`) await this._emitSuccess(res) @@ -626,30 +622,26 @@ export class BaseUpload { throw new DetailedError('tus: failed to create upload', err, req, undefined) } - const creationResponse = tusReadUploadCreationResponse({ - getHeader: (headerName) => res.getHeader(headerName), - status: res.getStatus(), + const creationResponsePlan = tusPlanUploadCreationResponse({ + followUp: 'patchIfNonempty', + response: tusReadUploadCreationResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }), + size: this._size, }) - if (!creationResponse.ok && creationResponse.reason === 'unexpectedStatus') { - throw new DetailedError( - TUS_FLOW_POLICY.messages.unexpectedCreateResponse, - undefined, - req, - res, - ) - } - if (!creationResponse.ok) { - throw new DetailedError(TUS_FLOW_POLICY.messages.uploadLocationMissing, undefined, req, res) + if (creationResponsePlan.action === 'fail') { + throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(endpoint, creationResponse.location) + this.url = resolveUrl(endpoint, creationResponsePlan.location) log(`Created upload at ${this.url}`) if (typeof this.options.onUploadUrlAvailable === 'function') { await this.options.onUploadUrlAvailable() } - if (tusCreatedUploadCompletesWithoutPatch({ size: this._size })) { + if (creationResponsePlan.action === 'complete') { // Nothing to upload and file was successfully created await this._emitSuccess(res) if (this._source) this._source.close() From ca964b2d9cc65183e5483c88ccb312b660d32209 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 12:41:46 +0200 Subject: [PATCH 023/155] Use generated TUS resume response plans --- lib/protocol_generated.ts | 74 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 40 +++++++-------------- 2 files changed, 87 insertions(+), 27 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index e040026e8..c20367042 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -271,6 +271,19 @@ export type TusResumeResponseStatusPlan = } | { action: 'readOffset' } +export type TusResumeOffsetResponsePlan = + | { + action: 'continue' + length: number | null + offset: number + uploadLengthDeferred: boolean + } + | { + action: 'fail' + message: string + reason: 'invalidLength' | 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' + } + export type TusCreateUploadValidationResult = | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } | { ok: true } @@ -303,6 +316,10 @@ export type TusUploadChunkResponsePlan = reason: 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' } +export type TusTerminateResponsePlan = + | { action: 'complete' } + | { action: 'fail'; message: string; reason: 'unexpectedStatus' } + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -535,6 +552,51 @@ export function tusPlanResumeResponseStatus({ return { action: 'create', removeStoredUpload } } +export function tusPlanResumeOffsetResponse({ + response, +}: { + response: TusUploadOffsetResponseReadResult +}): TusResumeOffsetResponsePlan { + if (!response.ok && response.reason === 'unexpectedStatus') { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.unexpectedResumeResponse, + reason: response.reason, + } + } + + if (!response.ok && response.reason === 'missingOffset') { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.missingResumeOffset, + reason: response.reason, + } + } + + if (!response.ok && response.reason === 'invalidOffset') { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.invalidResumeOffset, + reason: response.reason, + } + } + + if (!response.ok) { + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.invalidResumeLength, + reason: response.reason, + } + } + + return { + action: 'continue', + length: response.length, + offset: response.offset, + uploadLengthDeferred: response.uploadLengthDeferred, + } +} + export function tusUploadIsCompleteAfterOffset({ length, offset, @@ -723,6 +785,18 @@ export function tusShouldRetryStatus(status: number): boolean { ) } +export function tusPlanTerminateResponse({ status }: { status: number }): TusTerminateResponsePlan { + if (tusExpectedResponseStatusForOperation(TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, status)) { + return { action: 'complete' } + } + + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.unexpectedTerminateResponse, + reason: 'unexpectedStatus', + } +} + export function tusExpectedResponseStatusForOperation( operationId: string, status: number, diff --git a/lib/upload.ts b/lib/upload.ts index d5b99db2f..84e6c7c05 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -22,13 +22,14 @@ import { tusCreateUploadCompleteValue, tusCreateUploadRequestPlan, tusDeferredUploadLengthPlan, - tusExpectedResponseStatusForOperation, tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, tusPartialUploadHeaders, tusPatchUploadRequestPlan, + tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, tusPlanSingleUploadStart, + tusPlanTerminateResponse, tusPlanUploadChunkResponse, tusPlanUploadCompletionAfterOffset, tusPlanUploadCreationResponse, @@ -711,27 +712,16 @@ export class BaseUpload { protocol: this.options.protocol, status, }) - if (!offsetResponse.ok && offsetResponse.reason === 'unexpectedStatus') { - throw new DetailedError( - TUS_FLOW_POLICY.messages.unexpectedResumeResponse, - undefined, - req, - res, - ) - } - if (!offsetResponse.ok && offsetResponse.reason === 'missingOffset') { - throw new DetailedError(TUS_FLOW_POLICY.messages.missingResumeOffset, undefined, req, res) - } - if (!offsetResponse.ok && offsetResponse.reason === 'invalidOffset') { - throw new DetailedError(TUS_FLOW_POLICY.messages.invalidResumeOffset, undefined, req, res) - } - if (!offsetResponse.ok) { - throw new DetailedError(TUS_FLOW_POLICY.messages.invalidResumeLength, undefined, req, res) + const offsetResponsePlan = tusPlanResumeOffsetResponse({ + response: offsetResponse, + }) + if (offsetResponsePlan.action === 'fail') { + throw new DetailedError(offsetResponsePlan.message, undefined, req, res) } - const offset = offsetResponse.offset - const length = offsetResponse.length - this._uploadLengthDeferred = offsetResponse.uploadLengthDeferred + const offset = offsetResponsePlan.offset + const length = offsetResponsePlan.length + this._uploadLengthDeferred = offsetResponsePlan.uploadLengthDeferred if (typeof this.options.onUploadUrlAvailable === 'function') { await this.options.onUploadUrlAvailable() @@ -1167,16 +1157,12 @@ export async function terminate(url: string, options: UploadOptions): Promise Date: Wed, 27 May 2026 12:58:31 +0200 Subject: [PATCH 024/155] Use generated TUS retry planning --- lib/protocol_generated.ts | 51 ++++++++++++++++ lib/upload.ts | 118 +++++++++++++++++++++----------------- 2 files changed, 115 insertions(+), 54 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index c20367042..850d43dca 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -320,6 +320,17 @@ export type TusTerminateResponsePlan = | { action: 'complete' } | { action: 'fail'; message: string; reason: 'unexpectedStatus' } +export type TusRetryAfterErrorPlan = + | { action: 'emitError'; retryAttempt: number } + | { action: 'evaluatePolicy'; retryAttempt: number } + | { + action: 'retry' + delay: number + nextRetryAttempt: number + offsetBeforeRetry: number + retryAttempt: number + } + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -674,6 +685,46 @@ export function tusShouldResetRetryAttempt({ return offset > offsetBeforeRetry } +export function tusPlanRetryAfterError({ + isNetworkError, + offset, + offsetBeforeRetry, + retryAttempt, + retryDelays, + shouldRetry, +}: { + isNetworkError: boolean + offset: number + offsetBeforeRetry: number + retryAttempt: number + retryDelays: readonly number[] | null + shouldRetry?: boolean +}): TusRetryAfterErrorPlan { + const effectiveRetryAttempt = tusShouldResetRetryAttempt({ offset, offsetBeforeRetry }) + ? 0 + : retryAttempt + + if (retryDelays == null || effectiveRetryAttempt >= retryDelays.length || !isNetworkError) { + return { action: 'emitError', retryAttempt: effectiveRetryAttempt } + } + + if (shouldRetry == null) { + return { action: 'evaluatePolicy', retryAttempt: effectiveRetryAttempt } + } + + if (!shouldRetry) { + return { action: 'emitError', retryAttempt: effectiveRetryAttempt } + } + + return { + action: 'retry', + delay: retryDelays[effectiveRetryAttempt], + nextRetryAttempt: effectiveRetryAttempt + 1, + offsetBeforeRetry: offset, + retryAttempt: effectiveRetryAttempt, + } +} + export function tusShouldStoreUpload({ fingerprint, hasUrlStorageKey, diff --git a/lib/upload.ts b/lib/upload.ts index 84e6c7c05..25ad7f84c 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -28,6 +28,7 @@ import { tusPatchUploadRequestPlan, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, + tusPlanRetryAfterError, tusPlanSingleUploadStart, tusPlanTerminateResponse, tusPlanUploadChunkResponse, @@ -38,7 +39,6 @@ import { tusReadUploadCreationResponse, tusReadUploadOffsetResponse, tusRequestIdHeaders, - tusShouldResetRetryAttempt, tusShouldRetryStatus, tusShouldSendUploadBodyDuringCreation, tusTerminateUploadRequestPlan, @@ -486,30 +486,36 @@ export class BaseUpload { // Do not retry if explicitly aborted if (this._aborted) return - // Check if we should retry, when enabled, before sending the error to the user. - if (this.options.retryDelays != null) { - // We will reset the attempt counter if - // - we were already able to connect to the server (offset != null) and - // - we were able to upload a small chunk of data to the server - if ( - tusShouldResetRetryAttempt({ - offset: this._offset, - offsetBeforeRetry: this._offsetBeforeRetry, - }) - ) { - this._retryAttempt = 0 - } + const retryableErr = getRetryableError(err) + const retryInput = { + isNetworkError: retryableErr != null, + offset: this._offset, + offsetBeforeRetry: this._offsetBeforeRetry, + retryDelays: this.options.retryDelays, + } + let retryPlan = tusPlanRetryAfterError({ + ...retryInput, + retryAttempt: this._retryAttempt, + }) + this._retryAttempt = retryPlan.retryAttempt - if (shouldRetry(err, this._retryAttempt, this.options)) { - const delay = this.options.retryDelays[this._retryAttempt++] + if (retryPlan.action === 'evaluatePolicy' && retryableErr != null) { + retryPlan = tusPlanRetryAfterError({ + ...retryInput, + retryAttempt: retryPlan.retryAttempt, + shouldRetry: shouldRetryByPolicy(retryableErr, retryPlan.retryAttempt, this.options), + }) + this._retryAttempt = retryPlan.retryAttempt + } - this._offsetBeforeRetry = this._offset + if (retryPlan.action === 'retry') { + this._retryAttempt = retryPlan.nextRetryAttempt + this._offsetBeforeRetry = retryPlan.offsetBeforeRetry - this._retryTimeout = setTimeout(() => { - this.start() - }, delay) - return - } + this._retryTimeout = setTimeout(() => { + this.start() + }, retryPlan.delay) + return } // If we are not retrying, emit the error to the user. @@ -1050,35 +1056,22 @@ function isOnline(): boolean { } /** - * Checks whether or not it is ok to retry a request. - * @param {Error|DetailedError} err the error returned from the last request - * @param {number} retryAttempt the number of times the request has already been retried - * @param {object} options tus Upload options - * - * @api private + * Extract errors that originated from a request. Only these can safely be retried. */ -function shouldRetry( - err: Error | DetailedError, +function getRetryableError(err: Error): DetailedError | null { + if (err instanceof DetailedError && err.originalRequest != null) { + return err + } + + return null +} + +function shouldRetryByPolicy( + err: DetailedError, retryAttempt: number, options: UploadOptions, ): boolean { - // We only attempt a retry if - // - retryDelays option is set - // - we didn't exceed the maxium number of retries, yet, and - // - this error was caused by a request or it's response and - // - the error has a retryable response status, or - // a onShouldRetry is specified and returns true - // - the browser does not indicate that we are offline - const isNetworkError = 'originalRequest' in err && err.originalRequest != null - if ( - options.retryDelays == null || - retryAttempt >= options.retryDelays.length || - !isNetworkError - ) { - return false - } - - if (options && typeof options.onShouldRetry === 'function') { + if (typeof options.onShouldRetry === 'function') { return options.onShouldRetry(err, retryAttempt, options) } @@ -1173,21 +1166,38 @@ export async function terminate(url: string, options: UploadOptions): Promise Date: Wed, 27 May 2026 13:27:35 +0200 Subject: [PATCH 025/155] Use generated TUS upload size planning --- lib/protocol_generated.ts | 44 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 26 ++++++++--------------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 850d43dca..6a2a94cba 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -172,8 +172,11 @@ export const TUS_FLOW_POLICY = { messages: { configuredUploadSizeMismatch: 'upload was configured with a size of {expectedSize} bytes, but the source is done after {actualSize} bytes', + cannotDeriveUploadSize: + "tus: cannot automatically derive upload's size from input. Specify it manually using the `uploadSize` option or use the `uploadLengthDeferred` option", createMissingEndpoint: 'tus: unable to create upload because no endpoint is provided', createMissingSize: 'tus: expected _size to be set', + invalidUploadSize: 'tus: cannot convert `uploadSize` option into a number', invalidChunkOffset: 'tus: invalid or missing offset value', invalidResumeLength: 'tus: invalid or missing length value', invalidResumeOffset: 'tus: invalid Upload-Offset header', @@ -288,6 +291,10 @@ export type TusCreateUploadValidationResult = | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } | { ok: true } +export type TusPreparedUploadSizePlan = + | { ok: false; message: string; reason: 'cannotDeriveUploadSize' | 'invalidUploadSize' } + | { ok: true; size: number | null } + export type TusDeferredUploadLengthPlan = | { shouldDeclareLength: false } | { shouldDeclareLength: true; size: number } @@ -494,6 +501,43 @@ export function tusCreateUploadCompleteValue({ return uploadDataDuringCreation ? undefined : false } +export function tusPlanPreparedUploadSize({ + sourceSize, + uploadLengthDeferred, + uploadSize, +}: { + sourceSize: number | null | undefined + uploadLengthDeferred: boolean + uploadSize: unknown +}): TusPreparedUploadSizePlan { + if (uploadLengthDeferred) { + return { ok: true, size: null } + } + + if (uploadSize != null) { + const size = Number(uploadSize) + if (Number.isNaN(size)) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.invalidUploadSize, + reason: 'invalidUploadSize', + } + } + + return { ok: true, size } + } + + if (sourceSize == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.cannotDeriveUploadSize, + reason: 'cannotDeriveUploadSize', + } + } + + return { ok: true, size: sourceSize } +} + export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | null }): boolean { return size === 0 } diff --git a/lib/upload.ts b/lib/upload.ts index 25ad7f84c..1ffd23dfe 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -26,6 +26,7 @@ import { tusGetUploadOffsetRequestPlan, tusPartialUploadHeaders, tusPatchUploadRequestPlan, + tusPlanPreparedUploadSize, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, tusPlanRetryAfterError, @@ -220,24 +221,15 @@ export class BaseUpload { 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. - if (this._uploadLengthDeferred) { - this._size = null - } else if (this.options.uploadSize != null) { - this._size = Number(this.options.uploadSize) - if (Number.isNaN(this._size)) { - throw new Error('tus: cannot convert `uploadSize` option into a number') - } - } else { - this._size = this._source.size - if (this._size == null) { - throw new Error( - "tus: cannot automatically derive upload's size from input. Specify it manually using the `uploadSize` option or use the `uploadLengthDeferred` option", - ) - } + const preparedUploadSizePlan = tusPlanPreparedUploadSize({ + sourceSize: this._source.size, + uploadLengthDeferred: this._uploadLengthDeferred, + uploadSize: this.options.uploadSize, + }) + if (!preparedUploadSizePlan.ok) { + throw new Error(preparedUploadSizePlan.message) } + this._size = preparedUploadSizePlan.size // If the upload was configured to use multiple requests or if we resume from // an upload which used multiple requests, we start a parallel upload. From 1be5e16f0cc55aaa2f8c3ac467ddea1efe6402ff Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 13:52:31 +0200 Subject: [PATCH 026/155] Use generated TUS upload mode planning --- lib/protocol_generated.ts | 16 ++++++++++++++++ lib/upload.ts | 15 ++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 6a2a94cba..b628bce9b 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -295,6 +295,8 @@ export type TusPreparedUploadSizePlan = | { ok: false; message: string; reason: 'cannotDeriveUploadSize' | 'invalidUploadSize' } | { ok: true; size: number | null } +export type TusPreparedUploadModePlan = { action: 'parallel' } | { action: 'single' } + export type TusDeferredUploadLengthPlan = | { shouldDeclareLength: false } | { shouldDeclareLength: true; size: number } @@ -538,6 +540,20 @@ export function tusPlanPreparedUploadSize({ return { ok: true, size: sourceSize } } +export function tusPlanPreparedUploadMode({ + hasParallelUploadUrls, + parallelUploads, +}: { + hasParallelUploadUrls: boolean + parallelUploads: number +}): TusPreparedUploadModePlan { + if (hasParallelUploadUrls || parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads) { + return { action: 'parallel' } + } + + return { action: 'single' } +} + export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | null }): boolean { return size === 0 } diff --git a/lib/upload.ts b/lib/upload.ts index 1ffd23dfe..b4b94dba0 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -26,6 +26,7 @@ import { tusGetUploadOffsetRequestPlan, tusPartialUploadHeaders, tusPatchUploadRequestPlan, + tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, @@ -231,13 +232,17 @@ export class BaseUpload { } this._size = preparedUploadSizePlan.size - // If the upload was configured to use multiple requests or if we resume from - // an upload which used multiple requests, we start a parallel upload. - if (this.options.parallelUploads > 1 || this._parallelUploadUrls != null) { + const preparedUploadModePlan = tusPlanPreparedUploadMode({ + hasParallelUploadUrls: this._parallelUploadUrls != null, + parallelUploads: this.options.parallelUploads, + }) + + if (preparedUploadModePlan.action === 'parallel') { await this._startParallelUpload() - } else { - await this._startSingleUpload() + return } + + await this._startSingleUpload() } /** From 9ebd11dcb93723e2638c21f1c24a3c9e5a59dd7a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 13:57:03 +0200 Subject: [PATCH 027/155] Clear Puppeteer browser cache in CI --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4562f253e..f34676252 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,9 @@ jobs: run: npm ci - name: Install Puppeteer browser if: matrix.suite == 'puppeteer' - run: npx puppeteer browsers install chrome + run: | + rm -rf ~/.cache/puppeteer/chrome + npx puppeteer browsers install chrome - name: Build run: npm run build - name: Test From 0b7bd2992813348232390fdf48ab789fad57006f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 10:16:44 +0200 Subject: [PATCH 028/155] Use generated TUS parallel upload part planning --- lib/protocol_generated.ts | 74 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 63 +++++++-------------------------- 2 files changed, 86 insertions(+), 51 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index b628bce9b..dbd3c6a8e 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -189,6 +189,7 @@ export const TUS_FLOW_POLICY = { 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', parallelBoundariesWithoutParallelUploads: 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + parallelUploadMissingSize: 'tus: Expected _size to be set', parallelUploadsWithDeferredLength: 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', parallelUploadsWithUploadSize: @@ -206,6 +207,9 @@ export const TUS_FLOW_POLICY = { unsupportedProtocolPrefix: 'tus: unsupported protocol ', }, minimumParallelUploads: 2, + parallelUploadSplit: { + strategy: 'contiguous-floor-size-last-remainder', + }, } export type TusNumericHeaderReadResult = @@ -297,6 +301,14 @@ export type TusPreparedUploadSizePlan = export type TusPreparedUploadModePlan = { action: 'parallel' } | { action: 'single' } +export type TusParallelUploadBoundary = { end: number; start: number } + +export type TusParallelUploadPart = TusParallelUploadBoundary & { uploadUrl: string | null } + +export type TusParallelUploadPartsPlan = + | { ok: false; message: string; reason: 'missingSize' } + | { ok: true; parts: TusParallelUploadPart[]; totalSize: number } + export type TusDeferredUploadLengthPlan = | { shouldDeclareLength: false } | { shouldDeclareLength: true; size: number } @@ -554,6 +566,68 @@ export function tusPlanPreparedUploadMode({ return { action: 'single' } } +function tusSplitSizeIntoParallelUploadBoundaries({ + partCount, + totalSize, +}: { + partCount: number + totalSize: number +}): TusParallelUploadBoundary[] { + if (TUS_FLOW_POLICY.parallelUploadSplit.strategy !== 'contiguous-floor-size-last-remainder') { + throw new Error( + `tus: unsupported parallel upload split strategy ${TUS_FLOW_POLICY.parallelUploadSplit.strategy}`, + ) + } + + const partSize = Math.floor(totalSize / partCount) + const parts: TusParallelUploadBoundary[] = [] + + for (let index = 0; index < partCount; index += 1) { + parts.push({ + end: partSize * (index + 1), + start: partSize * index, + }) + } + + parts[partCount - 1].end = totalSize + + return parts +} + +export function tusPlanParallelUploadParts({ + parallelUploadBoundaries, + parallelUploads, + parallelUploadUrls, + size, +}: { + parallelUploadBoundaries: readonly TusParallelUploadBoundary[] | null | undefined + parallelUploads: number + parallelUploadUrls: readonly string[] | null | undefined + size: number | null +}): TusParallelUploadPartsPlan { + if (size == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.parallelUploadMissingSize, + reason: 'missingSize', + } + } + + const partCount = parallelUploadUrls != null ? parallelUploadUrls.length : parallelUploads + const boundaries = + parallelUploadBoundaries ?? + tusSplitSizeIntoParallelUploadBoundaries({ partCount, totalSize: size }) + + return { + ok: true, + parts: boundaries.map((part, index) => ({ + ...part, + uploadUrl: parallelUploadUrls?.[index] || null, + })), + totalSize: size, + } +} + export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | null }): boolean { return size === 0 } diff --git a/lib/upload.ts b/lib/upload.ts index b4b94dba0..7442007a1 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -26,6 +26,7 @@ import { tusGetUploadOffsetRequestPlan, tusPartialUploadHeaders, tusPatchUploadRequestPlan, + tusPlanParallelUploadParts, tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, tusPlanResumeOffsetResponse, @@ -252,29 +253,19 @@ export class BaseUpload { * @api private */ private async _startParallelUpload(): Promise { - const totalSize = this._size - let totalProgress = 0 - this._parallelUploads = [] - - const partCount = - this._parallelUploadUrls != null - ? this._parallelUploadUrls.length - : this.options.parallelUploads - - if (this._size == null) { - throw new Error('tus: Expected _size to be set') + const parallelUploadPartsPlan = tusPlanParallelUploadParts({ + parallelUploadBoundaries: this.options.parallelUploadBoundaries, + parallelUploads: this.options.parallelUploads, + parallelUploadUrls: this._parallelUploadUrls, + size: this._size, + }) + if (!parallelUploadPartsPlan.ok) { + throw new Error(parallelUploadPartsPlan.message) } - // The input file will be split into multiple slices which are uploaded in separate - // requests. Here we get the start and end position for the slices. - const partsBoundaries = - this.options.parallelUploadBoundaries ?? splitSizeIntoParts(this._size, partCount) - - // Attach URLs from previous uploads, if available. - const parts = partsBoundaries.map((part, index) => ({ - ...part, - uploadUrl: this._parallelUploadUrls?.[index] || null, - })) + const { parts, totalSize } = parallelUploadPartsPlan + let totalProgress = 0 + this._parallelUploads = [] // Create an empty list for storing the upload URLs this._parallelUploadUrls = new Array(parts.length) @@ -315,9 +306,6 @@ export class BaseUpload { onProgress: (newPartProgress: number) => { totalProgress = totalProgress - lastPartProgress + newPartProgress lastPartProgress = newPartProgress - if (totalSize == null) { - throw new Error('tus: Expected totalSize to be set') - } this._emitProgress(totalProgress, totalSize) }, // Wait until every partial upload has an upload URL, so we can add @@ -1095,33 +1083,6 @@ function resolveUrl(origin: string, link: string): string { return new URL(link, origin).toString() } -type Part = { start: number; end: number } - -/** - * Calculate the start and end positions for the parts if an upload - * is split into multiple parallel requests. - * - * @param {number} totalSize The byte size of the upload, which will be split. - * @param {number} partCount The number in how many parts the upload will be split. - * @return {Part[]} - * @api private - */ -function splitSizeIntoParts(totalSize: number, partCount: number): Part[] { - const partSize = Math.floor(totalSize / partCount) - const parts: Part[] = [] - - for (let i = 0; i < partCount; i++) { - parts.push({ - start: partSize * i, - end: partSize * (i + 1), - }) - } - - parts[partCount - 1].end = totalSize - - return parts -} - function wait(delay: number) { return new Promise((resolve) => { setTimeout(resolve, delay) From c6ee1258a564b5ec75070a74b72c703b5a24fdf9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 10:20:31 +0200 Subject: [PATCH 029/155] Install pinned Puppeteer Chrome in CI --- .github/workflows/ci.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f34676252..8993093e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,18 @@ jobs: if: matrix.suite == 'puppeteer' run: | rm -rf ~/.cache/puppeteer/chrome - npx puppeteer browsers install chrome + chrome_build="$( + node <<'NODE' + const executablePath = require('puppeteer').executablePath() + const match = executablePath.match(/chrome\/[^/]+-([^/]+)/) + if (!match) { + throw new Error(`Could not infer Puppeteer Chrome build from ${executablePath}`) + } + process.stdout.write(match[1]) + NODE + )" + npx puppeteer browsers install "chrome@$chrome_build" + test -x "$(node -e "process.stdout.write(require('puppeteer').executablePath())")" - name: Build run: npm run build - name: Test From b35ccc009df6181b7de6816a8da1207d281c36a8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 10:24:53 +0200 Subject: [PATCH 030/155] Prefer system Chrome for Puppeteer CI --- .github/workflows/ci.yml | 23 +++++++++-------------- test/karma/puppeteer.conf.cjs | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8993093e3..46e13d45e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,22 +40,17 @@ jobs: deno-version: latest - name: Install dependencies run: npm ci - - name: Install Puppeteer browser + - name: Configure Puppeteer browser if: matrix.suite == 'puppeteer' run: | - rm -rf ~/.cache/puppeteer/chrome - chrome_build="$( - node <<'NODE' - const executablePath = require('puppeteer').executablePath() - const match = executablePath.match(/chrome\/[^/]+-([^/]+)/) - if (!match) { - throw new Error(`Could not infer Puppeteer Chrome build from ${executablePath}`) - } - process.stdout.write(match[1]) - NODE - )" - npx puppeteer browsers install "chrome@$chrome_build" - test -x "$(node -e "process.stdout.write(require('puppeteer').executablePath())")" + 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 diff --git a/test/karma/puppeteer.conf.cjs b/test/karma/puppeteer.conf.cjs index 3855e7d91..aa693105d 100644 --- a/test/karma/puppeteer.conf.cjs +++ b/test/karma/puppeteer.conf.cjs @@ -2,7 +2,7 @@ const baseConfig = require('./base.conf.cjs') // Configure to use Puppeteer. See https://github.com/karma-runner/karma-chrome-launcher#available-browsers -process.env.CHROME_BIN = require('puppeteer').executablePath() +process.env.CHROME_BIN ||= require('puppeteer').executablePath() module.exports = (config) => { baseConfig(config) From ed5d39870fb54d5d6f9d473cb5e4a849a6c7c201 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 10:56:03 +0200 Subject: [PATCH 031/155] Use generated TUS partial upload options --- lib/protocol_generated.ts | 63 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 26 +++++----------- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index dbd3c6a8e..bfd5209c7 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -207,6 +207,12 @@ export const TUS_FLOW_POLICY = { unsupportedProtocolPrefix: 'tus: unsupported protocol ', }, minimumParallelUploads: 2, + parallelPartialUpload: { + headerKind: 'partial-upload', + metadataSource: 'metadataForPartialUploads', + nestedParallelUploads: 'disabled', + urlStorage: 'parent-managed', + }, parallelUploadSplit: { strategy: 'contiguous-floor-size-last-remainder', }, @@ -309,6 +315,16 @@ export type TusParallelUploadPartsPlan = | { ok: false; message: string; reason: 'missingSize' } | { ok: true; parts: TusParallelUploadPart[]; totalSize: number } +export interface TusParallelPartialUploadOptionsPlan { + headers: Record + metadata: Record + parallelUploadBoundaries: null + parallelUploads: number + removeFingerprintOnSuccess: boolean + storeFingerprintForResuming: boolean + uploadUrl: string | null +} + export type TusDeferredUploadLengthPlan = | { shouldDeclareLength: false } | { shouldDeclareLength: true; size: number } @@ -628,6 +644,53 @@ export function tusPlanParallelUploadParts({ } } +function tusAssertParallelPartialUploadPolicySupported(): void { + const policy = TUS_FLOW_POLICY.parallelPartialUpload + + if (policy.headerKind !== 'partial-upload') { + throw new Error(`tus: unsupported partial upload header kind ${policy.headerKind}`) + } + + if (policy.metadataSource !== 'metadataForPartialUploads') { + throw new Error(`tus: unsupported partial upload metadata source ${policy.metadataSource}`) + } + + if (policy.nestedParallelUploads !== 'disabled') { + throw new Error( + `tus: unsupported nested parallel upload policy ${policy.nestedParallelUploads}`, + ) + } + + if (policy.urlStorage !== 'parent-managed') { + throw new Error(`tus: unsupported partial upload URL storage policy ${policy.urlStorage}`) + } +} + +export function tusPlanParallelPartialUploadOptions({ + headers, + metadataForPartialUploads, + uploadUrl, +}: { + headers: Record + metadataForPartialUploads: Record + uploadUrl: string | null +}): TusParallelPartialUploadOptionsPlan { + tusAssertParallelPartialUploadPolicySupported() + + return { + headers: { + ...headers, + ...tusPartialUploadHeaders(), + }, + metadata: metadataForPartialUploads, + parallelUploadBoundaries: null, + parallelUploads: 1, + removeFingerprintOnSuccess: false, + storeFingerprintForResuming: false, + uploadUrl: uploadUrl || null, + } +} + export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | null }): boolean { return size === 0 } diff --git a/lib/upload.ts b/lib/upload.ts index 7442007a1..d7dfa61d1 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -24,8 +24,8 @@ import { tusDeferredUploadLengthPlan, tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, - tusPartialUploadHeaders, tusPatchUploadRequestPlan, + tusPlanParallelPartialUploadOptions, tusPlanParallelUploadParts, tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, @@ -279,25 +279,15 @@ export class BaseUpload { const { value } = await this._source.slice(part.start, part.end) return new Promise((resolve, reject) => { - // Merge with the user supplied options but overwrite some values. + const partialUploadOptions = tusPlanParallelPartialUploadOptions({ + headers: this.options.headers, + metadataForPartialUploads: this.options.metadataForPartialUploads, + uploadUrl: part.uploadUrl, + }) + const options = { ...this.options, - // If available, the partial upload should be resumed from a previous URL. - uploadUrl: part.uploadUrl || null, - // We take manually care of resuming for partial uploads, so they should - // not be stored in the URL storage. - storeFingerprintForResuming: false, - removeFingerprintOnSuccess: false, - // Reset the parallelUploads option to not cause recursion. - parallelUploads: 1, - // Reset this option as we are not doing a parallel upload. - parallelUploadBoundaries: null, - metadata: this.options.metadataForPartialUploads, - // Add the header to indicate the this is a partial upload. - headers: { - ...this.options.headers, - ...tusPartialUploadHeaders(), - }, + ...partialUploadOptions, // Reject or resolve the promise if the upload errors or completes. onSuccess: resolve, onError: reject, From baa895cf570324cc3082b666c701e081e9126a06 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 11:08:10 +0200 Subject: [PATCH 032/155] Use generated TUS final upload plan --- lib/protocol_generated.ts | 46 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 21 +++++++++--------- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index bfd5209c7..2642fb981 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -176,6 +176,8 @@ export const TUS_FLOW_POLICY = { "tus: cannot automatically derive upload's size from input. Specify it manually using the `uploadSize` option or use the `uploadLengthDeferred` option", createMissingEndpoint: 'tus: unable to create upload because no endpoint is provided', createMissingSize: 'tus: expected _size to be set', + finalUploadMissingPartialUrls: 'tus: Expected _parallelUploadUrls to be set', + finalUploadRequestFailed: 'tus: failed to concatenate parallel uploads', invalidUploadSize: 'tus: cannot convert `uploadSize` option into a number', invalidChunkOffset: 'tus: invalid or missing offset value', invalidResumeLength: 'tus: invalid or missing length value', @@ -325,6 +327,19 @@ export interface TusParallelPartialUploadOptionsPlan { uploadUrl: string | null } +export type TusFinalUploadCreationPlan = + | { + ok: false + message: string + reason: 'missingEndpoint' | 'missingPartialUploadUrls' + } + | { + endpoint: string + ok: true + requestErrorMessage: string + uploadUrls: readonly string[] + } + export type TusDeferredUploadLengthPlan = | { shouldDeclareLength: false } | { shouldDeclareLength: true; size: number } @@ -691,6 +706,37 @@ export function tusPlanParallelPartialUploadOptions({ } } +export function tusPlanFinalUploadCreation({ + endpoint, + partialUploadUrls, +}: { + endpoint: string | null | undefined + partialUploadUrls: readonly string[] | null | undefined +}): TusFinalUploadCreationPlan { + if (endpoint == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.createMissingEndpoint, + reason: 'missingEndpoint', + } + } + + if (partialUploadUrls == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.finalUploadMissingPartialUrls, + reason: 'missingPartialUploadUrls', + } + } + + return { + endpoint, + ok: true, + requestErrorMessage: TUS_FLOW_POLICY.messages.finalUploadRequestFailed, + uploadUrls: partialUploadUrls, + } +} + export function tusCreatedUploadCompletesWithoutPatch({ size }: { size: number | null }): boolean { return size === 0 } diff --git a/lib/upload.ts b/lib/upload.ts index d7dfa61d1..a9895a91e 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -25,6 +25,7 @@ import { tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, tusPatchUploadRequestPlan, + tusPlanFinalUploadCreation, tusPlanParallelPartialUploadOptions, tusPlanParallelUploadParts, tusPlanPreparedUploadMode, @@ -330,20 +331,20 @@ export class BaseUpload { // creating the final upload. await Promise.all(uploads) - const endpoint = this.options.endpoint - if (endpoint == null) { - throw new Error(TUS_FLOW_POLICY.messages.createMissingEndpoint) - } - if (!this._parallelUploadUrls) { - throw new Error('tus: Expected _parallelUploadUrls to be set') + const finalUploadCreationPlan = tusPlanFinalUploadCreation({ + endpoint: this.options.endpoint, + partialUploadUrls: this._parallelUploadUrls, + }) + if (!finalUploadCreationPlan.ok) { + throw new Error(finalUploadCreationPlan.message) } const req = this._openRequest( tusFinalUploadRequestPlan({ - endpoint, + endpoint: finalUploadCreationPlan.endpoint, encodeMetadataValue, metadata: this.options.metadata, protocol: this.options.protocol, - uploadUrls: this._parallelUploadUrls, + uploadUrls: finalUploadCreationPlan.uploadUrls, }), ) @@ -355,7 +356,7 @@ export class BaseUpload { throw new Error(`tus: value thrown that is not an error: ${err}`) } - throw new DetailedError('tus: failed to concatenate parallel uploads', err, req, undefined) + throw new DetailedError(finalUploadCreationPlan.requestErrorMessage, err, req, undefined) } const creationResponsePlan = tusPlanUploadCreationResponse({ @@ -370,7 +371,7 @@ export class BaseUpload { throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(endpoint, creationResponsePlan.location) + this.url = resolveUrl(finalUploadCreationPlan.endpoint, creationResponsePlan.location) log(`Created upload at ${this.url}`) await this._emitSuccess(res) From 4b288c92a83497126bb9dc77a2fa2c35baeb1d7a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 11:22:39 +0200 Subject: [PATCH 033/155] Use generated TUS upload creation plan --- lib/protocol_generated.ts | 46 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 26 +++++++++------------- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 2642fb981..8428bfd1c 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -176,6 +176,7 @@ export const TUS_FLOW_POLICY = { "tus: cannot automatically derive upload's size from input. Specify it manually using the `uploadSize` option or use the `uploadLengthDeferred` option", createMissingEndpoint: 'tus: unable to create upload because no endpoint is provided', createMissingSize: 'tus: expected _size to be set', + createUploadRequestFailed: 'tus: failed to create upload', finalUploadMissingPartialUrls: 'tus: Expected _parallelUploadUrls to be set', finalUploadRequestFailed: 'tus: failed to concatenate parallel uploads', invalidUploadSize: 'tus: cannot convert `uploadSize` option into a number', @@ -303,6 +304,15 @@ export type TusCreateUploadValidationResult = | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } | { ok: true } +export type TusUploadCreationRequestPlan = + | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } + | { + endpoint: string + ok: true + requestErrorMessage: string + uploadComplete: boolean | undefined + } + export type TusPreparedUploadSizePlan = | { ok: false; message: string; reason: 'cannotDeriveUploadSize' | 'invalidUploadSize' } | { ok: true; size: number | null } @@ -546,6 +556,42 @@ export function tusCreateUploadCompleteValue({ return uploadDataDuringCreation ? undefined : false } +export function tusPlanUploadCreationRequest({ + endpoint, + size, + uploadDataDuringCreation, + uploadLengthDeferred, +}: { + endpoint: string | null | undefined + size: number | null + uploadDataDuringCreation: boolean + uploadLengthDeferred: boolean +}): TusUploadCreationRequestPlan { + const validation = tusValidateCreateUpload({ + hasEndpoint: endpoint != null, + size, + uploadLengthDeferred, + }) + if (!validation.ok) { + return validation + } + + if (endpoint == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.createMissingEndpoint, + reason: 'missingEndpoint', + } + } + + return { + endpoint, + ok: true, + requestErrorMessage: TUS_FLOW_POLICY.messages.createUploadRequestFailed, + uploadComplete: tusCreateUploadCompleteValue({ uploadDataDuringCreation }), + } +} + export function tusPlanPreparedUploadSize({ sourceSize, uploadLengthDeferred, diff --git a/lib/upload.ts b/lib/upload.ts index a9895a91e..857ec8fcd 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -19,7 +19,6 @@ import { type TusRequestPlan, tusCheckConfiguredUploadSize, tusChunkEnd, - tusCreateUploadCompleteValue, tusCreateUploadRequestPlan, tusDeferredUploadLengthPlan, tusFinalUploadRequestPlan, @@ -37,6 +36,7 @@ import { tusPlanTerminateResponse, tusPlanUploadChunkResponse, tusPlanUploadCompletionAfterOffset, + tusPlanUploadCreationRequest, tusPlanUploadCreationResponse, tusPlanUploadStorage, tusReadUploadChunkResponse, @@ -48,7 +48,6 @@ import { tusTerminateUploadRequestPlan, tusUploadBodyHeaders, tusUploadLengthHeaders, - tusValidateCreateUpload, tusValidateUploadStart, } from './protocol_generated.js' import { uuid } from './uuid.js' @@ -557,29 +556,24 @@ export class BaseUpload { * @api private */ private async _createUpload(): Promise { - const endpoint = this.options.endpoint - const validation = tusValidateCreateUpload({ - hasEndpoint: endpoint != null, + const creationRequestPlan = tusPlanUploadCreationRequest({ + endpoint: this.options.endpoint, size: this._size, + uploadDataDuringCreation: this.options.uploadDataDuringCreation, uploadLengthDeferred: this._uploadLengthDeferred, }) - if (!validation.ok) { - throw new Error(validation.message) - } - if (endpoint == null) { - throw new Error(TUS_FLOW_POLICY.messages.createMissingEndpoint) + if (!creationRequestPlan.ok) { + throw new Error(creationRequestPlan.message) } const req = this._openRequest( tusCreateUploadRequestPlan({ - endpoint, + endpoint: creationRequestPlan.endpoint, encodeMetadataValue, metadata: this.options.metadata, protocol: this.options.protocol, size: this._size, - uploadComplete: tusCreateUploadCompleteValue({ - uploadDataDuringCreation: this.options.uploadDataDuringCreation, - }), + uploadComplete: creationRequestPlan.uploadComplete, uploadLengthDeferred: this._uploadLengthDeferred, }), ) @@ -602,7 +596,7 @@ export class BaseUpload { throw new Error(`tus: value thrown that is not an error: ${err}`) } - throw new DetailedError('tus: failed to create upload', err, req, undefined) + throw new DetailedError(creationRequestPlan.requestErrorMessage, err, req, undefined) } const creationResponsePlan = tusPlanUploadCreationResponse({ @@ -617,7 +611,7 @@ export class BaseUpload { throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(endpoint, creationResponsePlan.location) + this.url = resolveUrl(creationRequestPlan.endpoint, creationResponsePlan.location) log(`Created upload at ${this.url}`) if (typeof this.options.onUploadUrlAvailable === 'function') { From d6049b07929aabb49d6bb30edfa7d8994be995a8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 13:21:06 +0200 Subject: [PATCH 034/155] Use generated TUS resume request plan --- lib/protocol_generated.ts | 25 +++++++++++++++++++++++++ lib/upload.ts | 12 ++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 8428bfd1c..9462d73c7 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -199,6 +199,7 @@ export const TUS_FLOW_POLICY = { 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', parallelUploadsWithUploadUrl: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + resumeUploadRequestFailed: 'tus: failed to resume upload', resumeWithoutEndpoint: 'tus: unable to resume upload (new upload cannot be created without an endpoint)', retryDelaysNotArray: 'tus: the `retryDelays` option must either be an array or null', @@ -300,6 +301,10 @@ export type TusResumeOffsetResponsePlan = reason: 'invalidLength' | 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' } +export type TusResumeUploadRequestPlan = + | { ok: false; message: string; reason: 'missingUploadUrl' } + | { ok: true; requestErrorMessage: string; uploadUrl: string } + export type TusCreateUploadValidationResult = | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } | { ok: true } @@ -514,6 +519,26 @@ export function tusPlanSingleUploadStart({ return { action: 'create', logMessage: 'Creating a new upload' } } +export function tusPlanResumeUploadRequest({ + uploadUrl, +}: { + uploadUrl: string | null | undefined +}): TusResumeUploadRequestPlan { + if (uploadUrl == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.missingPatchUrl, + reason: 'missingUploadUrl', + } + } + + return { + ok: true, + requestErrorMessage: TUS_FLOW_POLICY.messages.resumeUploadRequestFailed, + uploadUrl, + } +} + export function tusValidateCreateUpload({ hasEndpoint, size, diff --git a/lib/upload.ts b/lib/upload.ts index 857ec8fcd..2fee574ec 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -31,6 +31,7 @@ import { tusPlanPreparedUploadSize, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, + tusPlanResumeUploadRequest, tusPlanRetryAfterError, tusPlanSingleUploadStart, tusPlanTerminateResponse, @@ -643,13 +644,16 @@ export class BaseUpload { * @api private */ private async _resumeUpload(): Promise { - if (this.url == null) { - throw new Error(TUS_FLOW_POLICY.messages.missingPatchUrl) + const resumeRequestPlan = tusPlanResumeUploadRequest({ + uploadUrl: this.url, + }) + if (!resumeRequestPlan.ok) { + throw new Error(resumeRequestPlan.message) } const req = this._openRequest( tusGetUploadOffsetRequestPlan({ protocol: this.options.protocol, - uploadUrl: this.url, + uploadUrl: resumeRequestPlan.uploadUrl, }), ) @@ -661,7 +665,7 @@ export class BaseUpload { throw new Error(`tus: value thrown that is not an error: ${err}`) } - throw new DetailedError('tus: failed to resume upload', err, req, undefined) + throw new DetailedError(resumeRequestPlan.requestErrorMessage, err, req, undefined) } const status = res.getStatus() From 1383b7db01ad30cc9c297e267570447721e0ee86 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 14:16:17 +0200 Subject: [PATCH 035/155] Use generated TUS chunk request plan --- lib/protocol_generated.ts | 29 +++++++++++++++++++++++++++++ lib/upload.ts | 19 +++++++++---------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 9462d73c7..c13ca86b5 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -207,6 +207,7 @@ export const TUS_FLOW_POLICY = { unexpectedCreateResponse: 'tus: unexpected response while creating upload', unexpectedResumeResponse: 'tus: unexpected response while resuming upload', unexpectedTerminateResponse: 'tus: unexpected response while terminating upload', + uploadChunkRequestFailed: 'tus: failed to upload chunk at offset {offset}', uploadLocationMissing: 'tus: invalid or missing Location header', unsupportedProtocolPrefix: 'tus: unsupported protocol ', }, @@ -305,6 +306,10 @@ export type TusResumeUploadRequestPlan = | { ok: false; message: string; reason: 'missingUploadUrl' } | { ok: true; requestErrorMessage: string; uploadUrl: string } +export type TusUploadChunkRequestPlan = + | { ok: false; message: string; reason: 'missingUploadUrl' } + | { ok: true; requestErrorMessage: string; uploadUrl: string } + export type TusCreateUploadValidationResult = | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } | { ok: true } @@ -539,6 +544,30 @@ export function tusPlanResumeUploadRequest({ } } +export function tusPlanUploadChunkRequest({ + offset, + uploadUrl, +}: { + offset: number + uploadUrl: string | null | undefined +}): TusUploadChunkRequestPlan { + if (uploadUrl == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.missingPatchUrl, + reason: 'missingUploadUrl', + } + } + + return { + ok: true, + requestErrorMessage: tusFormatFlowMessage(TUS_FLOW_POLICY.messages.uploadChunkRequestFailed, { + offset, + }), + uploadUrl, + } +} + export function tusValidateCreateUpload({ hasEndpoint, size, diff --git a/lib/upload.ts b/lib/upload.ts index 2fee574ec..a69e17af0 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -15,7 +15,6 @@ import type { } from './options.js' import { TUS_DEFAULT_CLIENT_PROTOCOL, - TUS_FLOW_POLICY, type TusRequestPlan, tusCheckConfiguredUploadSize, tusChunkEnd, @@ -35,6 +34,7 @@ import { tusPlanRetryAfterError, tusPlanSingleUploadStart, tusPlanTerminateResponse, + tusPlanUploadChunkRequest, tusPlanUploadChunkResponse, tusPlanUploadCompletionAfterOffset, tusPlanUploadCreationRequest, @@ -739,15 +739,19 @@ export class BaseUpload { let req: HttpRequest - if (this.url == null) { - throw new Error(TUS_FLOW_POLICY.messages.missingPatchUrl) + const chunkRequestPlan = tusPlanUploadChunkRequest({ + offset: this._offset, + uploadUrl: this.url, + }) + if (!chunkRequestPlan.ok) { + throw new Error(chunkRequestPlan.message) } req = this._openRequest( tusPatchUploadRequestPlan({ offset: this._offset, overridePatchMethod: this.options.overridePatchMethod, protocol: this.options.protocol, - uploadUrl: this.url, + uploadUrl: chunkRequestPlan.uploadUrl, }), ) @@ -764,12 +768,7 @@ export class BaseUpload { throw new Error(`tus: value thrown that is not an error: ${err}`) } - throw new DetailedError( - `tus: failed to upload chunk at offset ${this._offset}`, - err, - req, - undefined, - ) + throw new DetailedError(chunkRequestPlan.requestErrorMessage, err, req, undefined) } await this._handleUploadResponse(req, res) From bfd5d5e2f5861070680f607be5b086851d96dc0f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 14:21:42 +0200 Subject: [PATCH 036/155] Use generated TUS protocol constants --- lib/options.ts | 11 +++++++---- lib/protocol_generated.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/options.ts b/lib/options.ts index 53a1e284a..2bca53fd8 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -1,9 +1,12 @@ import type { Readable as NodeReadableStream } from 'node:stream' import type { DetailedError } from './DetailedError.js' +import type { TusClientProtocol } from './protocol_generated.js' -export const PROTOCOL_TUS_V1 = 'tus-v1' -export const PROTOCOL_IETF_DRAFT_03 = 'ietf-draft-03' -export const PROTOCOL_IETF_DRAFT_05 = 'ietf-draft-05' +export { + PROTOCOL_IETF_DRAFT_03, + PROTOCOL_IETF_DRAFT_05, + PROTOCOL_TUS_V1, +} from './protocol_generated.js' /** * ReactNativeFile describes the structure that is returned from the @@ -83,7 +86,7 @@ export interface UploadOptions { fileReader: FileReader httpStack: HttpStack - protocol: typeof PROTOCOL_TUS_V1 | typeof PROTOCOL_IETF_DRAFT_03 | typeof PROTOCOL_IETF_DRAFT_05 + protocol: TusClientProtocol } export interface OnSuccessPayload { diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index c13ca86b5..552c7761f 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -6,6 +6,15 @@ export const TUS_DEFAULT_PROTOCOL_VERSION = '1.0.0' export const TUS_DEFAULT_CLIENT_PROTOCOL = 'tus-v1' +export const PROTOCOL_TUS_V1 = 'tus-v1' +export const PROTOCOL_IETF_DRAFT_03 = 'ietf-draft-03' +export const PROTOCOL_IETF_DRAFT_05 = 'ietf-draft-05' + +export type TusClientProtocol = + | typeof PROTOCOL_TUS_V1 + | typeof PROTOCOL_IETF_DRAFT_03 + | typeof PROTOCOL_IETF_DRAFT_05 + export const TUS_HTTP_METHODS = { DELETE: 'DELETE', GET: 'GET', From d90386cc89f2f314dc3d9dcd897b4fd39eae5b74 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 14:26:37 +0200 Subject: [PATCH 037/155] Use generated TUS terminate request plan --- lib/protocol_generated.ts | 17 +++++++++++++++++ lib/upload.ts | 6 ++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 552c7761f..f1c4af806 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -212,6 +212,7 @@ export const TUS_FLOW_POLICY = { resumeWithoutEndpoint: 'tus: unable to resume upload (new upload cannot be created without an endpoint)', retryDelaysNotArray: 'tus: the `retryDelays` option must either be an array or null', + terminateUploadRequestFailed: 'tus: failed to terminate upload', unexpectedChunkResponse: 'tus: unexpected response while uploading chunk', unexpectedCreateResponse: 'tus: unexpected response while creating upload', unexpectedResumeResponse: 'tus: unexpected response while resuming upload', @@ -319,6 +320,11 @@ export type TusUploadChunkRequestPlan = | { ok: false; message: string; reason: 'missingUploadUrl' } | { ok: true; requestErrorMessage: string; uploadUrl: string } +export interface TusTerminateUploadRequestPlan { + requestErrorMessage: string + uploadUrl: string +} + export type TusCreateUploadValidationResult = | { ok: false; message: string; reason: 'missingEndpoint' | 'missingSize' } | { ok: true } @@ -577,6 +583,17 @@ export function tusPlanUploadChunkRequest({ } } +export function tusPlanTerminateUploadRequest({ + uploadUrl, +}: { + uploadUrl: string +}): TusTerminateUploadRequestPlan { + return { + requestErrorMessage: TUS_FLOW_POLICY.messages.terminateUploadRequestFailed, + uploadUrl, + } +} + export function tusValidateCreateUpload({ hasEndpoint, size, diff --git a/lib/upload.ts b/lib/upload.ts index a69e17af0..ab19ae673 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -34,6 +34,7 @@ import { tusPlanRetryAfterError, tusPlanSingleUploadStart, tusPlanTerminateResponse, + tusPlanTerminateUploadRequest, tusPlanUploadChunkRequest, tusPlanUploadChunkResponse, tusPlanUploadCompletionAfterOffset, @@ -1088,9 +1089,10 @@ function wait(delay: number) { * @return {Promise} The Promise will be resolved/rejected when the requests finish. */ export async function terminate(url: string, options: UploadOptions): Promise { + const terminateRequestPlan = tusPlanTerminateUploadRequest({ uploadUrl: url }) const plan = tusTerminateUploadRequestPlan({ protocol: options.protocol, - uploadUrl: url, + uploadUrl: terminateRequestPlan.uploadUrl, }) const req = openRequest(plan, options) @@ -1110,7 +1112,7 @@ export async function terminate(url: string, options: UploadOptions): Promise Date: Thu, 28 May 2026 14:54:32 +0200 Subject: [PATCH 038/155] Use generated TUS URL storage keys --- lib/browser/urlStorage.ts | 11 ++++++++--- lib/node/FileUrlStorage.ts | 11 ++++++++--- lib/protocol_generated.ts | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/lib/browser/urlStorage.ts b/lib/browser/urlStorage.ts index e20f76798..ac17931a7 100644 --- a/lib/browser/urlStorage.ts +++ b/lib/browser/urlStorage.ts @@ -1,4 +1,9 @@ import type { PreviousUpload, UrlStorage } from '../options.js' +import { + tusUrlStorageAllUploadsPrefix, + tusUrlStorageFingerprintPrefix, + tusUrlStorageKey, +} from '../protocol_generated.js' let hasStorage = false try { @@ -29,12 +34,12 @@ export const canStoreURLs = hasStorage export class WebStorageUrlStorage implements UrlStorage { findAllUploads(): Promise { - const results = this._findEntries('tus::') + const results = this._findEntries(tusUrlStorageAllUploadsPrefix()) return Promise.resolve(results) } findUploadsByFingerprint(fingerprint: string): Promise { - const results = this._findEntries(`tus::${fingerprint}::`) + const results = this._findEntries(tusUrlStorageFingerprintPrefix({ fingerprint })) return Promise.resolve(results) } @@ -45,7 +50,7 @@ export class WebStorageUrlStorage implements UrlStorage { addUpload(fingerprint: string, upload: PreviousUpload): Promise { const id = Math.round(Math.random() * 1e12) - const key = `tus::${fingerprint}::${id}` + const key = tusUrlStorageKey({ fingerprint, id }) localStorage.setItem(key, JSON.stringify(upload)) return Promise.resolve(key) diff --git a/lib/node/FileUrlStorage.ts b/lib/node/FileUrlStorage.ts index 76e3ed5b4..59bf184a0 100644 --- a/lib/node/FileUrlStorage.ts +++ b/lib/node/FileUrlStorage.ts @@ -1,6 +1,11 @@ import { readFile, writeFile } from 'node:fs/promises' import { lock } from 'proper-lockfile' import type { PreviousUpload, UrlStorage } from '../options.js' +import { + tusUrlStorageAllUploadsPrefix, + tusUrlStorageFingerprintPrefix, + tusUrlStorageKey, +} from '../protocol_generated.js' export const canStoreURLs = true @@ -12,11 +17,11 @@ export class FileUrlStorage implements UrlStorage { } async findAllUploads(): Promise { - return await this._getItems('tus::') + return await this._getItems(tusUrlStorageAllUploadsPrefix()) } async findUploadsByFingerprint(fingerprint: string): Promise { - return await this._getItems(`tus::${fingerprint}`) + return await this._getItems(tusUrlStorageFingerprintPrefix({ fingerprint })) } async removeUpload(urlStorageKey: string): Promise { @@ -25,7 +30,7 @@ export class FileUrlStorage implements UrlStorage { async addUpload(fingerprint: string, upload: PreviousUpload): Promise { const id = Math.round(Math.random() * 1e12) - const key = `tus::${fingerprint}::${id}` + const key = tusUrlStorageKey({ fingerprint, id }) await this._setItem(key, upload) return key diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index f1c4af806..6f6f0a743 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -231,6 +231,10 @@ export const TUS_FLOW_POLICY = { parallelUploadSplit: { strategy: 'contiguous-floor-size-last-remainder', }, + urlStorage: { + namespace: 'tus', + separator: '::', + }, } export type TusNumericHeaderReadResult = @@ -594,6 +598,24 @@ export function tusPlanTerminateUploadRequest({ } } +export function tusUrlStorageAllUploadsPrefix(): string { + return `${TUS_FLOW_POLICY.urlStorage.namespace}${TUS_FLOW_POLICY.urlStorage.separator}` +} + +export function tusUrlStorageFingerprintPrefix({ fingerprint }: { fingerprint: string }): string { + return `${tusUrlStorageAllUploadsPrefix()}${fingerprint}${TUS_FLOW_POLICY.urlStorage.separator}` +} + +export function tusUrlStorageKey({ + fingerprint, + id, +}: { + fingerprint: string + id: number | string +}): string { + return `${tusUrlStorageFingerprintPrefix({ fingerprint })}${id}` +} + export function tusValidateCreateUpload({ hasEndpoint, size, From 18410935b8f7246755f61b6fa10194eafef745ee Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 14:59:04 +0200 Subject: [PATCH 039/155] Use generated TUS flow failure planners --- lib/protocol_generated.ts | 43 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 14 ++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 6f6f0a743..a33605f4c 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -188,6 +188,7 @@ export const TUS_FLOW_POLICY = { createUploadRequestFailed: 'tus: failed to create upload', finalUploadMissingPartialUrls: 'tus: Expected _parallelUploadUrls to be set', finalUploadRequestFailed: 'tus: failed to concatenate parallel uploads', + fingerprintUnavailable: 'tus: unable to calculate fingerprint for this input file', invalidUploadSize: 'tus: cannot convert `uploadSize` option into a number', invalidChunkOffset: 'tus: invalid or missing offset value', invalidResumeLength: 'tus: invalid or missing length value', @@ -208,6 +209,8 @@ export const TUS_FLOW_POLICY = { 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', parallelUploadsWithUploadUrl: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + parallelUploadSliceMissingValue: + 'tus: no value returned while slicing file for parallel uploads', resumeUploadRequestFailed: 'tus: failed to resume upload', resumeWithoutEndpoint: 'tus: unable to resume upload (new upload cannot be created without an endpoint)', @@ -320,6 +323,14 @@ export type TusResumeUploadRequestPlan = | { ok: false; message: string; reason: 'missingUploadUrl' } | { ok: true; requestErrorMessage: string; uploadUrl: string } +export type TusFingerprintPlan = + | { ok: false; message: string; reason: 'missingFingerprint' } + | { fingerprint: string; ok: true } + +export type TusParallelUploadSlicePlan = + | { ok: false; message: string; reason: 'missingValue' } + | { ok: true } + export type TusUploadChunkRequestPlan = | { ok: false; message: string; reason: 'missingUploadUrl' } | { ok: true; requestErrorMessage: string; uploadUrl: string } @@ -563,6 +574,38 @@ export function tusPlanResumeUploadRequest({ } } +export function tusPlanFingerprint({ + fingerprint, +}: { + fingerprint: string | null | undefined +}): TusFingerprintPlan { + if (!fingerprint) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.fingerprintUnavailable, + reason: 'missingFingerprint', + } + } + + return { fingerprint, ok: true } +} + +export function tusPlanParallelUploadSlice({ + hasValue, +}: { + hasValue: boolean +}): TusParallelUploadSlicePlan { + if (!hasValue) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.parallelUploadSliceMissingValue, + reason: 'missingValue', + } + } + + return { ok: true } +} + export function tusPlanUploadChunkRequest({ offset, uploadUrl, diff --git a/lib/upload.ts b/lib/upload.ts index ab19ae673..1064ab2d2 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -24,8 +24,10 @@ import { tusGetUploadOffsetRequestPlan, tusPatchUploadRequestPlan, tusPlanFinalUploadCreation, + tusPlanFingerprint, tusPlanParallelPartialUploadOptions, tusPlanParallelUploadParts, + tusPlanParallelUploadSlice, tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, tusPlanResumeOffsetResponse, @@ -167,11 +169,12 @@ export class BaseUpload { async findPreviousUploads(): Promise { const fingerprint = await this.options.fingerprint(this.file, this.options) - if (!fingerprint) { - throw new Error('tus: unable to calculate fingerprint for this input file') + const fingerprintPlan = tusPlanFingerprint({ fingerprint }) + if (!fingerprintPlan.ok) { + throw new Error(fingerprintPlan.message) } - return await this.options.urlStorage.findUploadsByFingerprint(fingerprint) + return await this.options.urlStorage.findUploadsByFingerprint(fingerprintPlan.fingerprint) } resumeFromPreviousUpload(previousUpload: PreviousUpload): void { @@ -313,8 +316,9 @@ export class BaseUpload { }, } - if (value == null) { - reject(new Error('tus: no value returned while slicing file for parallel uploads')) + const slicePlan = tusPlanParallelUploadSlice({ hasValue: value != null }) + if (!slicePlan.ok) { + reject(new Error(slicePlan.message)) return } From e0b27eb22054bd743226e90104a34476e43a3ef6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 15:21:19 +0200 Subject: [PATCH 040/155] Use generated plan for removed resume warning --- lib/protocol_generated.ts | 21 +++++++++++++++++++++ lib/upload.ts | 11 ++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index a33605f4c..e397eed30 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -198,6 +198,8 @@ export const TUS_FLOW_POLICY = { missingInput: 'tus: no file or stream to upload provided', missingPatchUrl: 'tus: Expected url to be set', missingResumeOffset: 'tus: missing Upload-Offset header', + removedResumeOption: + 'tus: The `resume` option has been removed in tus-js-client v2. Please use the URL storage API instead.', parallelBoundariesLengthMismatch: 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', parallelBoundariesWithoutParallelUploads: @@ -433,6 +435,10 @@ export type TusRetryAfterErrorPlan = retryAttempt: number } +export type TusRemovedResumeOptionWarningPlan = + | { message: string; shouldWarn: true } + | { shouldWarn: false } + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -528,6 +534,21 @@ export function tusValidateUploadStart({ return { ok: true } } +export function tusPlanRemovedResumeOptionWarning({ + hasResumeOption, +}: { + hasResumeOption: boolean +}): TusRemovedResumeOptionWarningPlan { + if (!hasResumeOption) { + return { shouldWarn: false } + } + + return { + message: TUS_FLOW_POLICY.messages.removedResumeOption, + shouldWarn: true, + } +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, diff --git a/lib/upload.ts b/lib/upload.ts index 1064ab2d2..bf0aa633b 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -30,6 +30,7 @@ import { tusPlanParallelUploadSlice, tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, + tusPlanRemovedResumeOptionWarning, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, tusPlanResumeUploadRequest, @@ -148,11 +149,11 @@ export class BaseUpload { private _uploadLengthDeferred: boolean constructor(file: UploadInput, options: UploadOptions) { - // Warn about removed options from previous versions - if ('resume' in options) { - console.log( - 'tus: The `resume` option has been removed in tus-js-client v2. Please use the URL storage API instead.', - ) + const removedResumeOptionWarning = tusPlanRemovedResumeOptionWarning({ + hasResumeOption: 'resume' in options, + }) + if (removedResumeOptionWarning.shouldWarn) { + console.log(removedResumeOptionWarning.message) } // The default options will already be added from the wrapper classes. From ed451ee47ce14b8d3b27fcad0be3cd8391026a13 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 15:26:54 +0200 Subject: [PATCH 041/155] Use generated TUS runtime log plans --- lib/protocol_generated.ts | 32 ++++++++++++++++++++++++++++++++ lib/upload.ts | 14 +++++--------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index e397eed30..b8db073a0 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -186,9 +186,13 @@ export const TUS_FLOW_POLICY = { createMissingEndpoint: 'tus: unable to create upload because no endpoint is provided', createMissingSize: 'tus: expected _size to be set', createUploadRequestFailed: 'tus: failed to create upload', + createdUpload: 'Created upload at {uploadUrl}', finalUploadMissingPartialUrls: 'tus: Expected _parallelUploadUrls to be set', finalUploadRequestFailed: 'tus: failed to concatenate parallel uploads', + fingerprintCalculated: 'Calculated fingerprint: {fingerprint}', fingerprintUnavailable: 'tus: unable to calculate fingerprint for this input file', + fingerprintUnavailableForStorage: + 'No fingerprint was calculated meaning that the upload cannot be stored in the URL storage.', invalidUploadSize: 'tus: cannot convert `uploadSize` option into a number', invalidChunkOffset: 'tus: invalid or missing offset value', invalidResumeLength: 'tus: invalid or missing length value', @@ -439,6 +443,10 @@ export type TusRemovedResumeOptionWarningPlan = | { message: string; shouldWarn: true } | { shouldWarn: false } +export interface TusLogMessagePlan { + message: string +} + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -549,6 +557,30 @@ export function tusPlanRemovedResumeOptionWarning({ } } +export function tusPlanPreparedFingerprintLog({ + fingerprint, +}: { + fingerprint: string | null +}): TusLogMessagePlan { + if (fingerprint == null) { + return { message: TUS_FLOW_POLICY.messages.fingerprintUnavailableForStorage } + } + + return { + message: tusFormatFlowMessage(TUS_FLOW_POLICY.messages.fingerprintCalculated, { + fingerprint, + }), + } +} + +export function tusPlanCreatedUploadLog({ uploadUrl }: { uploadUrl: string }): TusLogMessagePlan { + return { + message: tusFormatFlowMessage(TUS_FLOW_POLICY.messages.createdUpload, { + uploadUrl, + }), + } +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, diff --git a/lib/upload.ts b/lib/upload.ts index bf0aa633b..eebac456a 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -23,11 +23,13 @@ import { tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, tusPatchUploadRequestPlan, + tusPlanCreatedUploadLog, tusPlanFinalUploadCreation, tusPlanFingerprint, tusPlanParallelPartialUploadOptions, tusPlanParallelUploadParts, tusPlanParallelUploadSlice, + tusPlanPreparedFingerprintLog, tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, tusPlanRemovedResumeOptionWarning, @@ -217,13 +219,7 @@ export class BaseUpload { private async _prepareAndStartUpload(): Promise { this._fingerprint = await this.options.fingerprint(this.file, this.options) - if (this._fingerprint == null) { - log( - 'No fingerprint was calculated meaning that the upload cannot be stored in the URL storage.', - ) - } else { - log(`Calculated fingerprint: ${this._fingerprint}`) - } + log(tusPlanPreparedFingerprintLog({ fingerprint: this._fingerprint }).message) if (this._source == null) { this._source = await this.options.fileReader.openFile(this.file, this.options.chunkSize) @@ -378,7 +374,7 @@ export class BaseUpload { } this.url = resolveUrl(finalUploadCreationPlan.endpoint, creationResponsePlan.location) - log(`Created upload at ${this.url}`) + log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) await this._emitSuccess(res) } @@ -619,7 +615,7 @@ export class BaseUpload { } this.url = resolveUrl(creationRequestPlan.endpoint, creationResponsePlan.location) - log(`Created upload at ${this.url}`) + log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) if (typeof this.options.onUploadUrlAvailable === 'function') { await this.options.onUploadUrlAvailable() From 1d17ffd3c51fecbcfabe38d3d58fb5f25975930c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 15:31:07 +0200 Subject: [PATCH 042/155] Use generated request id header in errors --- lib/DetailedError.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/DetailedError.ts b/lib/DetailedError.ts index 93ffd8f50..c7fe38412 100644 --- a/lib/DetailedError.ts +++ b/lib/DetailedError.ts @@ -1,4 +1,5 @@ import type { HttpRequest, HttpResponse } from './options.js' +import { TUS_REQUEST_ID_HEADER_NAME } from './protocol_generated.js' export class DetailedError extends Error { originalRequest?: HttpRequest @@ -19,7 +20,7 @@ export class DetailedError extends Error { } if (req != null) { - const requestId = req.getHeader('X-Request-ID') || 'n/a' + const requestId = req.getHeader(TUS_REQUEST_ID_HEADER_NAME) || 'n/a' const method = req.getMethod() const url = req.getURL() const status = res ? res.getStatus() : 'n/a' From af95ab01a4c51580c7a6bdcacd9f421bc750be42 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 15:35:44 +0200 Subject: [PATCH 043/155] Use generated message for non-error throws --- lib/protocol_generated.ts | 7 +++++++ lib/upload.ts | 13 +++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index b8db073a0..f5a94d631 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -198,6 +198,7 @@ export const TUS_FLOW_POLICY = { invalidResumeLength: 'tus: invalid or missing length value', invalidResumeOffset: 'tus: invalid Upload-Offset header', lockedUpload: 'tus: upload is currently locked; retry later', + nonErrorThrownValue: 'tus: value thrown that is not an error: {value}', missingEndpointOrUploadUrl: 'tus: neither an endpoint or an upload URL is provided', missingInput: 'tus: no file or stream to upload provided', missingPatchUrl: 'tus: Expected url to be set', @@ -581,6 +582,12 @@ export function tusPlanCreatedUploadLog({ uploadUrl }: { uploadUrl: string }): T } } +export function tusNonErrorThrownValueMessage({ value }: { value: unknown }): string { + return tusFormatFlowMessage(TUS_FLOW_POLICY.messages.nonErrorThrownValue, { + value: String(value), + }) +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, diff --git a/lib/upload.ts b/lib/upload.ts index eebac456a..901fe2ef9 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -22,6 +22,7 @@ import { tusDeferredUploadLengthPlan, tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, + tusNonErrorThrownValueMessage, tusPatchUploadRequestPlan, tusPlanCreatedUploadLog, tusPlanFinalUploadCreation, @@ -208,7 +209,7 @@ export class BaseUpload { // Its supposed to return immediately and start the upload in the background. this._prepareAndStartUpload().catch((err) => { if (!(err instanceof Error)) { - throw new Error(`tus: value thrown that is not an error: ${err}`) + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } // Errors from the actual upload requests will bubble up to here, where @@ -355,7 +356,7 @@ export class BaseUpload { res = await this._sendRequest(req) } catch (err) { if (!(err instanceof Error)) { - throw new Error(`tus: value thrown that is not an error: ${err}`) + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } throw new DetailedError(finalUploadCreationPlan.requestErrorMessage, err, req, undefined) @@ -596,7 +597,7 @@ export class BaseUpload { } } catch (err) { if (!(err instanceof Error)) { - throw new Error(`tus: value thrown that is not an error: ${err}`) + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } throw new DetailedError(creationRequestPlan.requestErrorMessage, err, req, undefined) @@ -664,7 +665,7 @@ export class BaseUpload { res = await this._sendRequest(req) } catch (err) { if (!(err instanceof Error)) { - throw new Error(`tus: value thrown that is not an error: ${err}`) + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } throw new DetailedError(resumeRequestPlan.requestErrorMessage, err, req, undefined) @@ -767,7 +768,7 @@ export class BaseUpload { } if (!(err instanceof Error)) { - throw new Error(`tus: value thrown that is not an error: ${err}`) + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } throw new DetailedError(chunkRequestPlan.requestErrorMessage, err, req, undefined) @@ -1107,7 +1108,7 @@ export async function terminate(url: string, options: UploadOptions): Promise Date: Thu, 28 May 2026 15:40:04 +0200 Subject: [PATCH 044/155] Use generated React Native file reader messages --- lib/browser/BrowserFileReader.ts | 10 ++++++---- lib/protocol_generated.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/browser/BrowserFileReader.ts b/lib/browser/BrowserFileReader.ts index d7b8ca09d..c96589a1f 100644 --- a/lib/browser/BrowserFileReader.ts +++ b/lib/browser/BrowserFileReader.ts @@ -3,6 +3,10 @@ import { supportedTypes as supportedBaseTypes, } from '../commonFileReader.js' import type { FileReader, FileSource, UploadInput } from '../options.js' +import { + tusReactNativeUriBlobFetchFailedMessage, + tusReactNativeUriUnsupportedMessage, +} from '../protocol_generated.js' import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js' import { uriToBlob } from '../reactnative/uriToBlob.js' import { BlobFileSource } from '../sources/BlobFileSource.js' @@ -15,16 +19,14 @@ 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 })) } } diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index f5a94d631..8d7fec7d9 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -218,6 +218,10 @@ export const TUS_FLOW_POLICY = { 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', parallelUploadSliceMissingValue: 'tus: no value returned while slicing file for parallel uploads', + reactNativeUriBlobFetchFailed: + 'tus: cannot fetch `file.uri` as Blob, make sure the uri is correct and accessible. {error}', + reactNativeUriUnsupported: + 'tus: file objects with `uri` property is only supported in React Native', resumeUploadRequestFailed: 'tus: failed to resume upload', resumeWithoutEndpoint: 'tus: unable to resume upload (new upload cannot be created without an endpoint)', @@ -588,6 +592,16 @@ export function tusNonErrorThrownValueMessage({ value }: { value: unknown }): st }) } +export function tusReactNativeUriUnsupportedMessage(): string { + return TUS_FLOW_POLICY.messages.reactNativeUriUnsupported +} + +export function tusReactNativeUriBlobFetchFailedMessage({ error }: { error: unknown }): string { + return tusFormatFlowMessage(TUS_FLOW_POLICY.messages.reactNativeUriBlobFetchFailed, { + error: String(error), + }) +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, From f4345a04453ae5b8198d5a631a632091e6b09d33 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 15:46:28 +0200 Subject: [PATCH 045/155] Increase Puppeteer Karma activity timeout --- test/karma/puppeteer.conf.cjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/karma/puppeteer.conf.cjs b/test/karma/puppeteer.conf.cjs index aa693105d..2222cc99f 100644 --- a/test/karma/puppeteer.conf.cjs +++ b/test/karma/puppeteer.conf.cjs @@ -15,5 +15,9 @@ module.exports = (config) => { // start these browsers // available browser launchers: https://www.npmjs.com/search?q=keywords:karma-launcher browsers: ['ChromeHeadless'], + + // Chrome on shared GitHub runners can pause long enough for Karma's default + // 30s activity timeout to disconnect the browser even though specs are still passing. + browserNoActivityTimeout: 120000, }) } From 3389be39dfd4f4d12cc0071d23cd4a3851d532cd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 16:34:00 +0200 Subject: [PATCH 046/155] Use generated TUS file source policy --- lib/browser/BrowserFileReader.ts | 11 +-- lib/commonFileReader.ts | 21 ++--- lib/node/NodeFileReader.ts | 23 ++--- lib/node/sources/NodeStreamFileSource.ts | 8 +- lib/protocol_generated.ts | 106 +++++++++++++++++++++++ lib/sources/WebStreamFileSource.ts | 16 ++-- 6 files changed, 149 insertions(+), 36 deletions(-) diff --git a/lib/browser/BrowserFileReader.ts b/lib/browser/BrowserFileReader.ts index c96589a1f..c726432a7 100644 --- a/lib/browser/BrowserFileReader.ts +++ b/lib/browser/BrowserFileReader.ts @@ -1,11 +1,10 @@ -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' @@ -34,7 +33,9 @@ export class BrowserFileReader implements FileReader { if (fileSource) return fileSource throw new Error( - `in this environment the source object may only be an instance of: ${supportedBaseTypes.join(', ')}`, + tusUnsupportedSourceTypeMessage({ + supportedTypes: tusCommonSupportedFileSourceTypes(), + }), ) } } diff --git a/lib/commonFileReader.ts b/lib/commonFileReader.ts index 1ae7883c3..27cf1c29f 100644 --- a/lib/commonFileReader.ts +++ b/lib/commonFileReader.ts @@ -1,4 +1,8 @@ import type { FileSource, UploadInput } from './options.js' +import { + tusCommonSupportedFileSourceTypes, + tusValidateWebStreamChunkSize, +} from './protocol_generated.js' import { ArrayBufferViewFileSource } from './sources/ArrayBufferViewFileSource.js' import { BlobFileSource } from './sources/BlobFileSource.js' import { WebStreamFileSource } from './sources/WebStreamFileSource.js' @@ -34,11 +38,9 @@ export function openFile(input: UploadInput, chunkSize: number): FileSource | nu } if (input instanceof ReadableStream) { - chunkSize = Number(chunkSize) - if (!Number.isFinite(chunkSize)) { - throw new Error( - 'cannot create source for stream without a finite value for the `chunkSize` option', - ) + const chunkSizeValidation = tusValidateWebStreamChunkSize({ chunkSize }) + if (!chunkSizeValidation.ok) { + throw new Error(chunkSizeValidation.message) } return new WebStreamFileSource(input) @@ -47,11 +49,4 @@ export function openFile(input: UploadInput, chunkSize: number): FileSource | nu return null } -export const supportedTypes = [ - 'File', - 'Blob', - 'ArrayBuffer', - 'SharedArrayBuffer', - 'ArrayBufferView', - 'ReadableStream (Web Streams)', -] +export const supportedTypes = tusCommonSupportedFileSourceTypes() diff --git a/lib/node/NodeFileReader.ts b/lib/node/NodeFileReader.ts index c91e5d857..2ef293405 100644 --- a/lib/node/NodeFileReader.ts +++ b/lib/node/NodeFileReader.ts @@ -1,11 +1,13 @@ import { createReadStream } from 'node:fs' import isStream from 'is-stream' -import { - openFile as openBaseFile, - supportedTypes as supportedBaseTypes, -} from '../commonFileReader.js' +import { openFile as openBaseFile } from '../commonFileReader.js' import type { FileReader, PathReference, UploadInput } from '../options.js' +import { + tusNodeSupportedFileSourceTypes, + tusUnsupportedSourceTypeMessage, + tusValidateNodeStreamChunkSize, +} from '../protocol_generated.js' import { NodeStreamFileSource } from './sources/NodeStreamFileSource.js' import { getFileSourceFromPath } from './sources/PathFileSource.js' @@ -25,12 +27,11 @@ export class NodeFileReader implements FileReader { } if (isStream.readable(input)) { - chunkSize = Number(chunkSize) - if (!Number.isFinite(chunkSize)) { - throw new Error( - 'cannot create source for stream without a finite value for the `chunkSize` option; specify a chunkSize to control the memory consumption', - ) + const chunkSizeValidation = tusValidateNodeStreamChunkSize({ chunkSize }) + if (!chunkSizeValidation.ok) { + throw new Error(chunkSizeValidation.message) } + return Promise.resolve(new NodeStreamFileSource(input)) } @@ -38,7 +39,9 @@ export class NodeFileReader implements FileReader { if (fileSource) return Promise.resolve(fileSource) throw new Error( - `in this environment the source object may only be an instance of: ${supportedBaseTypes.join(', ')}, fs.ReadStream (Node.js), stream.Readable (Node.js)`, + tusUnsupportedSourceTypeMessage({ + supportedTypes: tusNodeSupportedFileSourceTypes(), + }), ) } } diff --git a/lib/node/sources/NodeStreamFileSource.ts b/lib/node/sources/NodeStreamFileSource.ts index 2f4f1b711..b89ed6e18 100644 --- a/lib/node/sources/NodeStreamFileSource.ts +++ b/lib/node/sources/NodeStreamFileSource.ts @@ -1,5 +1,9 @@ import type { Readable } from 'node:stream' import type { FileSource } from '../../options.js' +import { + tusNodeStreamBackwardsReadMessage, + tusNodeStreamStartOutsideBufferMessage, +} from '../../protocol_generated.js' /** * readChunk reads a chunk with the given size from the given @@ -78,11 +82,11 @@ export class NodeStreamFileSource implements FileSource { // Fail fast if the caller requests a proportion of the data which is not // available any more. if (start < this._bufPos) { - throw new Error('cannot slice from position which we already seeked away') + throw new Error(tusNodeStreamBackwardsReadMessage()) } if (start > this._bufPos + this._buf.length) { - throw new Error('slice start is outside of buffer (currently not implemented)') + throw new Error(tusNodeStreamStartOutsideBufferMessage()) } if (this._error) { diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 8d7fec7d9..bd65b96d5 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -178,6 +178,32 @@ export const TUS_UPLOAD_BODY = { } export const TUS_FLOW_POLICY = { + fileSources: { + commonTypes: [ + 'File', + 'Blob', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'ArrayBufferView', + 'ReadableStream (Web Streams)', + ], + messages: { + nodeStreamBackwardsRead: 'cannot slice from position which we already seeked away', + nodeStreamChunkSizeRequired: + 'cannot create source for stream without a finite value for the `chunkSize` option; specify a chunkSize to control the memory consumption', + nodeStreamStartOutsideBuffer: 'slice start is outside of buffer (currently not implemented)', + unsupportedSourceType: + 'in this environment the source object may only be an instance of: {supportedTypes}', + webStreamAlreadyLocked: + 'Readable stream is already locked to reader. tus-js-client cannot obtain a new reader.', + webStreamBackwardsRead: "Requested data is before the reader's current offset", + webStreamChunkSizeRequired: + 'cannot create source for stream without a finite value for the `chunkSize` option', + webStreamMissingBuffer: 'cannot _getDataFromBuffer because _buffer is unset', + webStreamUnknownDataType: 'Unknown data type', + }, + nodeExtraTypes: ['fs.ReadStream (Node.js)', 'stream.Readable (Node.js)'], + }, messages: { configuredUploadSizeMismatch: 'upload was configured with a size of {expectedSize} bytes, but the source is done after {actualSize} bytes', @@ -452,6 +478,10 @@ export interface TusLogMessagePlan { message: string } +export type TusFileSourceChunkSizeValidationResult = + | { chunkSize: number; ok: true } + | { message: string; ok: false; reason: 'missingFiniteChunkSize' } + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -602,6 +632,82 @@ export function tusReactNativeUriBlobFetchFailedMessage({ error }: { error: unkn }) } +export function tusCommonSupportedFileSourceTypes(): readonly string[] { + return [...TUS_FLOW_POLICY.fileSources.commonTypes] +} + +export function tusNodeSupportedFileSourceTypes(): readonly string[] { + return [...TUS_FLOW_POLICY.fileSources.commonTypes, ...TUS_FLOW_POLICY.fileSources.nodeExtraTypes] +} + +export function tusUnsupportedSourceTypeMessage({ + supportedTypes, +}: { + supportedTypes: readonly string[] +}): string { + return tusFormatFlowMessage(TUS_FLOW_POLICY.fileSources.messages.unsupportedSourceType, { + supportedTypes: supportedTypes.join(', '), + }) +} + +export function tusValidateWebStreamChunkSize({ + chunkSize, +}: { + chunkSize: unknown +}): TusFileSourceChunkSizeValidationResult { + const normalizedChunkSize = Number(chunkSize) + if (!Number.isFinite(normalizedChunkSize)) { + return { + message: TUS_FLOW_POLICY.fileSources.messages.webStreamChunkSizeRequired, + ok: false, + reason: 'missingFiniteChunkSize', + } + } + + return { chunkSize: normalizedChunkSize, ok: true } +} + +export function tusValidateNodeStreamChunkSize({ + chunkSize, +}: { + chunkSize: unknown +}): TusFileSourceChunkSizeValidationResult { + const normalizedChunkSize = Number(chunkSize) + if (!Number.isFinite(normalizedChunkSize)) { + return { + message: TUS_FLOW_POLICY.fileSources.messages.nodeStreamChunkSizeRequired, + ok: false, + reason: 'missingFiniteChunkSize', + } + } + + return { chunkSize: normalizedChunkSize, ok: true } +} + +export function tusWebStreamUnknownDataTypeMessage(): string { + return TUS_FLOW_POLICY.fileSources.messages.webStreamUnknownDataType +} + +export function tusWebStreamAlreadyLockedMessage(): string { + return TUS_FLOW_POLICY.fileSources.messages.webStreamAlreadyLocked +} + +export function tusWebStreamBackwardsReadMessage(): string { + return TUS_FLOW_POLICY.fileSources.messages.webStreamBackwardsRead +} + +export function tusWebStreamMissingBufferMessage(): string { + return TUS_FLOW_POLICY.fileSources.messages.webStreamMissingBuffer +} + +export function tusNodeStreamBackwardsReadMessage(): string { + return TUS_FLOW_POLICY.fileSources.messages.nodeStreamBackwardsRead +} + +export function tusNodeStreamStartOutsideBufferMessage(): string { + return TUS_FLOW_POLICY.fileSources.messages.nodeStreamStartOutsideBuffer +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, diff --git a/lib/sources/WebStreamFileSource.ts b/lib/sources/WebStreamFileSource.ts index 5d1f64343..59b65498f 100644 --- a/lib/sources/WebStreamFileSource.ts +++ b/lib/sources/WebStreamFileSource.ts @@ -1,4 +1,10 @@ import type { FileSource, SliceResult } from '../options.js' +import { + tusWebStreamAlreadyLockedMessage, + tusWebStreamBackwardsReadMessage, + tusWebStreamMissingBufferMessage, + tusWebStreamUnknownDataTypeMessage, +} from '../protocol_generated.js' function len(blobOrArray: WebStreamFileSource['_buffer']): number { if (blobOrArray === undefined) return 0 @@ -20,7 +26,7 @@ function concat(a: T, b: T): T { c.set(b, a.length) return c as T } - throw new Error('Unknown data type') + throw new Error(tusWebStreamUnknownDataTypeMessage()) } /** @@ -46,9 +52,7 @@ export class WebStreamFileSource implements FileSource { constructor(stream: ReadableStream) { if (stream.locked) { - throw new Error( - 'Readable stream is already locked to reader. tus-js-client cannot obtain a new reader.', - ) + throw new Error(tusWebStreamAlreadyLockedMessage()) } this._reader = stream.getReader() @@ -56,7 +60,7 @@ export class WebStreamFileSource implements FileSource { async slice(start: number, end: number): Promise { if (start < this._bufferOffset) { - throw new Error("Requested data is before the reader's current offset") + throw new Error(tusWebStreamBackwardsReadMessage()) } return await this._readUntilEnoughDataOrDone(start, end) @@ -98,7 +102,7 @@ export class WebStreamFileSource implements FileSource { private _getDataFromBuffer(start: number, end: number) { if (this._buffer === undefined) { - throw new Error('cannot _getDataFromBuffer because _buffer is unset') + throw new Error(tusWebStreamMissingBufferMessage()) } // Remove data from buffer before `start`. From dc70aa9c93cd4d06ad099e064ded996c60c5426e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 17:14:38 +0200 Subject: [PATCH 047/155] Use generated TUS client defaults --- lib/protocol_generated.ts | 70 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 16 ++------- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index bd65b96d5..8910599ee 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -262,6 +262,22 @@ export const TUS_FLOW_POLICY = { unsupportedProtocolPrefix: 'tus: unsupported protocol ', }, minimumParallelUploads: 2, + optionDefaults: { + addRequestId: false, + chunkSize: { + kind: 'unbounded', + }, + headers: {}, + metadata: {}, + metadataForPartialUploads: {}, + overridePatchMethod: false, + parallelUploads: 1, + removeFingerprintOnSuccess: false, + retryDelays: [0, 1000, 3000, 5000], + storeFingerprintForResuming: true, + uploadDataDuringCreation: false, + uploadLengthDeferred: false, + }, parallelPartialUpload: { headerKind: 'partial-upload', metadataSource: 'metadataForPartialUploads', @@ -478,6 +494,22 @@ export interface TusLogMessagePlan { message: string } +export interface TusClientDefaultOptions { + addRequestId: boolean + chunkSize: number + headers: Record + metadata: Record + metadataForPartialUploads: Record + overridePatchMethod: boolean + parallelUploads: number + protocol: TusClientProtocol + removeFingerprintOnSuccess: boolean + retryDelays: number[] + storeFingerprintForResuming: boolean + uploadDataDuringCreation: boolean + uploadLengthDeferred: boolean +} + export type TusFileSourceChunkSizeValidationResult = | { chunkSize: number; ok: true } | { message: string; ok: false; reason: 'missingFiniteChunkSize' } @@ -632,6 +664,44 @@ export function tusReactNativeUriBlobFetchFailedMessage({ error }: { error: unkn }) } +export function tusDefaultChunkSize(): number { + const chunkSize = TUS_FLOW_POLICY.optionDefaults.chunkSize + if (chunkSize.kind === 'unbounded') { + return Number.POSITIVE_INFINITY + } + + const bytes = 'value' in chunkSize ? Number(chunkSize.value) : Number.NaN + if (chunkSize.kind === 'bytes' && Number.isFinite(bytes)) { + return bytes + } + + throw new Error(`tus: unsupported default chunk size policy ${JSON.stringify(chunkSize)}`) +} + +export function tusDefaultRetryDelays(): number[] { + return [...TUS_FLOW_POLICY.optionDefaults.retryDelays] +} + +export function tusDefaultClientOptions(): TusClientDefaultOptions { + const defaults = TUS_FLOW_POLICY.optionDefaults + + return { + addRequestId: defaults.addRequestId, + chunkSize: tusDefaultChunkSize(), + headers: { ...defaults.headers }, + metadata: { ...defaults.metadata }, + metadataForPartialUploads: { ...defaults.metadataForPartialUploads }, + overridePatchMethod: defaults.overridePatchMethod, + parallelUploads: defaults.parallelUploads, + protocol: TUS_DEFAULT_CLIENT_PROTOCOL, + removeFingerprintOnSuccess: defaults.removeFingerprintOnSuccess, + retryDelays: tusDefaultRetryDelays(), + storeFingerprintForResuming: defaults.storeFingerprintForResuming, + uploadDataDuringCreation: defaults.uploadDataDuringCreation, + uploadLengthDeferred: defaults.uploadLengthDeferred, + } +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } diff --git a/lib/upload.ts b/lib/upload.ts index 901fe2ef9..1cabf17b1 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -14,11 +14,11 @@ import type { UploadOptions, } from './options.js' import { - TUS_DEFAULT_CLIENT_PROTOCOL, type TusRequestPlan, tusCheckConfiguredUploadSize, tusChunkEnd, tusCreateUploadRequestPlan, + tusDefaultClientOptions, tusDeferredUploadLengthPlan, tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, @@ -64,8 +64,6 @@ export const defaultOptions = { endpoint: undefined, uploadUrl: undefined, - metadata: {}, - metadataForPartialUploads: {}, fingerprint: undefined, uploadSize: undefined, @@ -75,27 +73,17 @@ export const defaultOptions = { onError: undefined, onUploadUrlAvailable: undefined, - overridePatchMethod: false, - headers: {}, - addRequestId: false, onBeforeRequest: undefined, onAfterResponse: undefined, onShouldRetry: defaultOnShouldRetry, - chunkSize: Number.POSITIVE_INFINITY, - retryDelays: [0, 1000, 3000, 5000], - parallelUploads: 1, parallelUploadBoundaries: undefined, - storeFingerprintForResuming: true, - removeFingerprintOnSuccess: false, - uploadLengthDeferred: false, - uploadDataDuringCreation: false, urlStorage: undefined, fileReader: undefined, httpStack: undefined, - protocol: TUS_DEFAULT_CLIENT_PROTOCOL as UploadOptions['protocol'], + ...tusDefaultClientOptions(), } export class BaseUpload { From 5ac273be79ed725ce515140237095b4df01ba2bb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 17:22:22 +0200 Subject: [PATCH 048/155] Use generated TUS request header policy --- lib/protocol_generated.ts | 36 ++++++++++++++++++++++++++++++++++++ lib/upload.ts | 28 ++++++++++++---------------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 8910599ee..d5f924414 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -287,6 +287,10 @@ export const TUS_FLOW_POLICY = { parallelUploadSplit: { strategy: 'contiguous-floor-size-last-remainder', }, + requestHeaders: { + layers: ['operation', 'custom', 'request-id'], + requestIdSource: 'sdk-generated-uuid', + }, urlStorage: { namespace: 'tus', separator: '::', @@ -702,6 +706,38 @@ export function tusDefaultClientOptions(): TusClientDefaultOptions { } } +export function tusPlanRequestHeaders({ + addRequestId, + customHeaders = {}, + operationHeaders, + requestId, +}: { + addRequestId: boolean + customHeaders?: Record + operationHeaders: Record + requestId?: string +}): Record { + const policy = TUS_FLOW_POLICY.requestHeaders + const supportedLayerOrder = 'operation|custom|request-id' + if (policy.layers.join('|') !== supportedLayerOrder) { + throw new Error(`tus: unsupported request header layer policy ${policy.layers.join('|')}`) + } + + if (policy.requestIdSource !== 'sdk-generated-uuid') { + throw new Error(`tus: unsupported request ID source ${policy.requestIdSource}`) + } + + if (addRequestId && !requestId) { + throw new Error('tus: request ID is required when addRequestId is enabled') + } + + return { + ...operationHeaders, + ...customHeaders, + ...(addRequestId && requestId ? tusRequestIdHeaders(requestId) : {}), + } +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } diff --git a/lib/upload.ts b/lib/upload.ts index 1cabf17b1..c60e15714 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -34,6 +34,7 @@ import { tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, tusPlanRemovedResumeOptionWarning, + tusPlanRequestHeaders, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, tusPlanResumeUploadRequest, @@ -50,7 +51,6 @@ import { tusReadUploadChunkResponse, tusReadUploadCreationResponse, tusReadUploadOffsetResponse, - tusRequestIdHeaders, tusShouldRetryStatus, tusShouldSendUploadBodyDuringCreation, tusTerminateUploadRequestPlan, @@ -957,21 +957,17 @@ function setRequestHeaders(req: HttpRequest, headers: Record): v */ function openRequest(plan: TusRequestPlan, options: UploadOptions): HttpRequest { const req = options.httpStack.createRequest(plan.method, plan.url) - - setRequestHeaders(req, plan.headers) - - const headers = options.headers || {} - - for (const [name, value] of Object.entries(headers)) { - req.setHeader(name, value) - } - - if (options.addRequestId) { - const requestId = uuid() - for (const [name, value] of Object.entries(tusRequestIdHeaders(requestId))) { - req.setHeader(name, value) - } - } + const requestId = options.addRequestId ? uuid() : undefined + + setRequestHeaders( + req, + tusPlanRequestHeaders({ + addRequestId: options.addRequestId, + customHeaders: options.headers || {}, + operationHeaders: plan.headers, + requestId, + }), + ) return req } From ae22b69b6a127136f8cbf891b3e797f57956f4ad Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 17:29:42 +0200 Subject: [PATCH 049/155] Use generated TUS Location resolution --- lib/protocol_generated.ts | 20 ++++++++++++++++++++ lib/upload.ts | 17 +++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index d5f924414..cdfc84649 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -204,6 +204,9 @@ export const TUS_FLOW_POLICY = { }, nodeExtraTypes: ['fs.ReadStream (Node.js)', 'stream.Readable (Node.js)'], }, + locationResolution: { + strategy: 'relative-to-creation-request-url', + }, messages: { configuredUploadSizeMismatch: 'upload was configured with a size of {expectedSize} bytes, but the source is done after {actualSize} bytes', @@ -738,6 +741,23 @@ export function tusPlanRequestHeaders({ } } +export function tusResolveUploadLocation({ + location, + requestUrl, + resolveRelativeUrl, +}: { + location: string + requestUrl: string + resolveRelativeUrl: (baseUrl: string, relativeOrAbsoluteUrl: string) => string +}): string { + const policy = TUS_FLOW_POLICY.locationResolution + if (policy.strategy !== 'relative-to-creation-request-url') { + throw new Error(`tus: unsupported Location resolution strategy ${policy.strategy}`) + } + + return resolveRelativeUrl(requestUrl, location) +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } diff --git a/lib/upload.ts b/lib/upload.ts index c60e15714..6a73c81ec 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -51,6 +51,7 @@ import { tusReadUploadChunkResponse, tusReadUploadCreationResponse, tusReadUploadOffsetResponse, + tusResolveUploadLocation, tusShouldRetryStatus, tusShouldSendUploadBodyDuringCreation, tusTerminateUploadRequestPlan, @@ -362,7 +363,11 @@ export class BaseUpload { throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(finalUploadCreationPlan.endpoint, creationResponsePlan.location) + this.url = tusResolveUploadLocation({ + location: creationResponsePlan.location, + requestUrl: finalUploadCreationPlan.endpoint, + resolveRelativeUrl, + }) log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) await this._emitSuccess(res) @@ -603,7 +608,11 @@ export class BaseUpload { throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(creationRequestPlan.endpoint, creationResponsePlan.location) + this.url = tusResolveUploadLocation({ + location: creationResponsePlan.location, + requestUrl: creationRequestPlan.endpoint, + resolveRelativeUrl, + }) log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) if (typeof this.options.onUploadUrlAvailable === 'function') { @@ -1054,8 +1063,8 @@ function defaultOnShouldRetry(err: DetailedError): boolean { * header with the value /upload/abc, the resolved URL will be: * http://example.com/upload/abc */ -function resolveUrl(origin: string, link: string): string { - return new URL(link, origin).toString() +function resolveRelativeUrl(baseUrl: string, relativeOrAbsoluteUrl: string): string { + return new URL(relativeOrAbsoluteUrl, baseUrl).toString() } function wait(delay: number) { From d5508b5b2183228e78b3505da815099468cf85b2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 18:05:47 +0200 Subject: [PATCH 050/155] Use generated TUS request lifecycle policy --- lib/protocol_generated.ts | 99 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 39 ++++++++++++--- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index cdfc84649..bf1d6f0f2 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -294,6 +294,19 @@ export const TUS_FLOW_POLICY = { layers: ['operation', 'custom', 'request-id'], requestIdSource: 'sdk-generated-uuid', }, + requestLifecycle: { + hooks: { + afterResponse: 'after-successful-transport-response', + beforeRequest: 'before-transport-send', + }, + retry: { + customDecision: 'custom-callback-before-default-decision', + defaultDecision: 'retryable-status-and-online', + evaluationTrigger: 'generated-plan-evaluate-policy', + onlineSignal: 'sdk-platform-online-status', + timer: 'sdk-platform-timer', + }, + }, urlStorage: { namespace: 'tus', separator: '::', @@ -517,6 +530,11 @@ export interface TusClientDefaultOptions { uploadLengthDeferred: boolean } +export interface TusRequestLifecycleHookPlan { + afterResponseHook: boolean + beforeRequestHook: boolean +} + export type TusFileSourceChunkSizeValidationResult = | { chunkSize: number; ok: true } | { message: string; ok: false; reason: 'missingFiniteChunkSize' } @@ -758,6 +776,87 @@ export function tusResolveUploadLocation({ return resolveRelativeUrl(requestUrl, location) } +function tusAssertRequestLifecyclePolicySupported(): void { + const policy = TUS_FLOW_POLICY.requestLifecycle + + if (policy.hooks.beforeRequest !== 'before-transport-send') { + throw new Error(`tus: unsupported before-request hook policy ${policy.hooks.beforeRequest}`) + } + + if (policy.hooks.afterResponse !== 'after-successful-transport-response') { + throw new Error(`tus: unsupported after-response hook policy ${policy.hooks.afterResponse}`) + } + + if (policy.retry.evaluationTrigger !== 'generated-plan-evaluate-policy') { + throw new Error(`tus: unsupported retry policy trigger ${policy.retry.evaluationTrigger}`) + } + + if (policy.retry.customDecision !== 'custom-callback-before-default-decision') { + throw new Error(`tus: unsupported custom retry decision ${policy.retry.customDecision}`) + } + + if (policy.retry.defaultDecision !== 'retryable-status-and-online') { + throw new Error(`tus: unsupported default retry decision ${policy.retry.defaultDecision}`) + } + + if (policy.retry.onlineSignal !== 'sdk-platform-online-status') { + throw new Error(`tus: unsupported retry online signal ${policy.retry.onlineSignal}`) + } + + if (policy.retry.timer !== 'sdk-platform-timer') { + throw new Error(`tus: unsupported retry timer policy ${policy.retry.timer}`) + } +} + +export function tusPlanRequestLifecycleHooks({ + hasAfterResponseHook, + hasBeforeRequestHook, +}: { + hasAfterResponseHook: boolean + hasBeforeRequestHook: boolean +}): TusRequestLifecycleHookPlan { + tusAssertRequestLifecyclePolicySupported() + + return { + afterResponseHook: hasAfterResponseHook, + beforeRequestHook: hasBeforeRequestHook, + } +} + +export function tusShouldEvaluateRetryPolicy({ + hasRetryableError, + retryPlanAction, +}: { + hasRetryableError: boolean + retryPlanAction: TusRetryAfterErrorPlan['action'] +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + return retryPlanAction === 'evaluatePolicy' && hasRetryableError +} + +export function tusShouldUseCustomRetryPolicy({ + hasCustomRetryPolicy, +}: { + hasCustomRetryPolicy: boolean +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + return hasCustomRetryPolicy +} + +export function tusDefaultRetryPolicyDecision({ + isOnline, + status, +}: { + isOnline: boolean + status: number +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + return tusShouldRetryStatus(status) && isOnline +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } diff --git a/lib/upload.ts b/lib/upload.ts index 6a73c81ec..932e0eacd 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -19,6 +19,7 @@ import { tusChunkEnd, tusCreateUploadRequestPlan, tusDefaultClientOptions, + tusDefaultRetryPolicyDecision, tusDeferredUploadLengthPlan, tusFinalUploadRequestPlan, tusGetUploadOffsetRequestPlan, @@ -35,6 +36,7 @@ import { tusPlanPreparedUploadSize, tusPlanRemovedResumeOptionWarning, tusPlanRequestHeaders, + tusPlanRequestLifecycleHooks, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, tusPlanResumeUploadRequest, @@ -52,8 +54,9 @@ import { tusReadUploadCreationResponse, tusReadUploadOffsetResponse, tusResolveUploadLocation, - tusShouldRetryStatus, + tusShouldEvaluateRetryPolicy, tusShouldSendUploadBodyDuringCreation, + tusShouldUseCustomRetryPolicy, tusTerminateUploadRequestPlan, tusUploadBodyHeaders, tusUploadLengthHeaders, @@ -471,7 +474,13 @@ export class BaseUpload { }) this._retryAttempt = retryPlan.retryAttempt - if (retryPlan.action === 'evaluatePolicy' && retryableErr != null) { + if ( + tusShouldEvaluateRetryPolicy({ + hasRetryableError: retryableErr != null, + retryPlanAction: retryPlan.action, + }) && + retryableErr != null + ) { retryPlan = tusPlanRetryAfterError({ ...retryInput, retryAttempt: retryPlan.retryAttempt, @@ -992,13 +1001,18 @@ async function sendRequest( body: SliceType | undefined, options: UploadOptions, ): Promise { - if (typeof options.onBeforeRequest === 'function') { + const lifecyclePlan = tusPlanRequestLifecycleHooks({ + hasAfterResponseHook: typeof options.onAfterResponse === 'function', + hasBeforeRequestHook: typeof options.onBeforeRequest === 'function', + }) + + if (lifecyclePlan.beforeRequestHook && typeof options.onBeforeRequest === 'function') { await options.onBeforeRequest(req) } const res = await req.send(body) - if (typeof options.onAfterResponse === 'function') { + if (lifecyclePlan.afterResponseHook && typeof options.onAfterResponse === 'function') { await options.onAfterResponse(req, res) } @@ -1040,7 +1054,12 @@ function shouldRetryByPolicy( retryAttempt: number, options: UploadOptions, ): boolean { - if (typeof options.onShouldRetry === 'function') { + if ( + tusShouldUseCustomRetryPolicy({ + hasCustomRetryPolicy: typeof options.onShouldRetry === 'function', + }) && + typeof options.onShouldRetry === 'function' + ) { return options.onShouldRetry(err, retryAttempt, options) } @@ -1054,7 +1073,7 @@ function shouldRetryByPolicy( */ function defaultOnShouldRetry(err: DetailedError): boolean { const status = err.originalResponse ? err.originalResponse.getStatus() : 0 - return tusShouldRetryStatus(status) && isOnline() + return tusDefaultRetryPolicyDecision({ isOnline: isOnline(), status }) } /** @@ -1119,7 +1138,13 @@ export async function terminate(url: string, options: UploadOptions): Promise Date: Thu, 28 May 2026 18:22:34 +0200 Subject: [PATCH 051/155] Use generated TUS upload URL hook policy --- lib/protocol_generated.ts | 48 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 21 ++++++++++++----- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index bf1d6f0f2..7d0301008 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -178,6 +178,13 @@ export const TUS_UPLOAD_BODY = { } export const TUS_FLOW_POLICY = { + eventHooks: { + uploadUrlAvailable: { + createUpload: 'after-url-known-before-storage', + parallelFinalUpload: 'not-emitted', + resumeUpload: 'after-url-known-before-storage', + }, + }, fileSources: { commonTypes: [ 'File', @@ -535,6 +542,15 @@ export interface TusRequestLifecycleHookPlan { beforeRequestHook: boolean } +export type TusUploadUrlAvailableHookContext = + | 'createUpload' + | 'parallelFinalUpload' + | 'resumeUpload' + +export interface TusUploadUrlAvailableHookPlan { + shouldCall: boolean +} + export type TusFileSourceChunkSizeValidationResult = | { chunkSize: number; ok: true } | { message: string; ok: false; reason: 'missingFiniteChunkSize' } @@ -857,6 +873,38 @@ export function tusDefaultRetryPolicyDecision({ return tusShouldRetryStatus(status) && isOnline } +function tusAssertUploadUrlAvailableHookPolicySupported(): void { + const policy = TUS_FLOW_POLICY.eventHooks.uploadUrlAvailable + + if (policy.createUpload !== 'after-url-known-before-storage') { + throw new Error(`tus: unsupported create upload URL hook policy ${policy.createUpload}`) + } + + if (policy.resumeUpload !== 'after-url-known-before-storage') { + throw new Error(`tus: unsupported resume upload URL hook policy ${policy.resumeUpload}`) + } + + if (policy.parallelFinalUpload !== 'not-emitted') { + throw new Error( + `tus: unsupported parallel final upload URL hook policy ${policy.parallelFinalUpload}`, + ) + } +} + +export function tusPlanUploadUrlAvailableHook({ + context, + hasHook, +}: { + context: TusUploadUrlAvailableHookContext + hasHook: boolean +}): TusUploadUrlAvailableHookPlan { + tusAssertUploadUrlAvailableHookPolicySupported() + + return { + shouldCall: hasHook && TUS_FLOW_POLICY.eventHooks.uploadUrlAvailable[context] !== 'not-emitted', + } +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } diff --git a/lib/upload.ts b/lib/upload.ts index 932e0eacd..43cc46002 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -15,6 +15,7 @@ import type { } from './options.js' import { type TusRequestPlan, + type TusUploadUrlAvailableHookContext, tusCheckConfiguredUploadSize, tusChunkEnd, tusCreateUploadRequestPlan, @@ -50,6 +51,7 @@ import { tusPlanUploadCreationRequest, tusPlanUploadCreationResponse, tusPlanUploadStorage, + tusPlanUploadUrlAvailableHook, tusReadUploadChunkResponse, tusReadUploadCreationResponse, tusReadUploadOffsetResponse, @@ -554,6 +556,17 @@ export class BaseUpload { } } + private async _emitUploadUrlAvailable(context: TusUploadUrlAvailableHookContext): Promise { + const hookPlan = tusPlanUploadUrlAvailableHook({ + context, + hasHook: typeof this.options.onUploadUrlAvailable === 'function', + }) + + if (hookPlan.shouldCall && typeof this.options.onUploadUrlAvailable === 'function') { + await this.options.onUploadUrlAvailable() + } + } + /** * Create a new upload using the creation extension by sending a POST * request to the endpoint. After successful creation the file will be @@ -624,9 +637,7 @@ export class BaseUpload { }) log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) - if (typeof this.options.onUploadUrlAvailable === 'function') { - await this.options.onUploadUrlAvailable() - } + await this._emitUploadUrlAvailable('createUpload') if (creationResponsePlan.action === 'complete') { // Nothing to upload and file was successfully created @@ -712,9 +723,7 @@ export class BaseUpload { const length = offsetResponsePlan.length this._uploadLengthDeferred = offsetResponsePlan.uploadLengthDeferred - if (typeof this.options.onUploadUrlAvailable === 'function') { - await this.options.onUploadUrlAvailable() - } + await this._emitUploadUrlAvailable('resumeUpload') await this._saveUploadInUrlStorage() From 4daad50755009ef790669601e6ae29b754f257d5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 18:31:21 +0200 Subject: [PATCH 052/155] Use generated TUS URL storage record policy --- lib/protocol_generated.ts | 97 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 26 +++++------ 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 7d0301008..3444b63e0 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -262,6 +262,9 @@ export const TUS_FLOW_POLICY = { resumeWithoutEndpoint: 'tus: unable to resume upload (new upload cannot be created without an endpoint)', retryDelaysNotArray: 'tus: the `retryDelays` option must either be an array or null', + storageMissingParallelUploadUrls: + 'tus: cannot store parallel upload because no partial upload URLs are available', + storageMissingUploadUrl: 'tus: cannot store upload because no upload URL is available', terminateUploadRequestFailed: 'tus: failed to terminate upload', unexpectedChunkResponse: 'tus: unexpected response while uploading chunk', unexpectedCreateResponse: 'tus: unexpected response while creating upload', @@ -316,6 +319,11 @@ export const TUS_FLOW_POLICY = { }, urlStorage: { namespace: 'tus', + record: { + creationTime: 'sdk-current-date-string', + missingUrl: 'fail', + storedUrlKind: 'single-or-parallel-upload-url', + }, separator: '::', }, } @@ -482,6 +490,19 @@ export type TusUploadStoragePlan = | { shouldStore: false } | { fingerprint: string; shouldStore: true } +export interface TusStoredUploadRecord { + creationTime: string + metadata: Record + parallelUploadUrls?: string[] + size: number | null + uploadUrl?: string + urlStorageKey: string +} + +export type TusStoredUploadRecordPlan = + | { ok: false; message: string; reason: 'missingParallelUploadUrls' | 'missingUploadUrl' } + | { ok: true; upload: TusStoredUploadRecord } + export type TusUploadCreationFollowUp = 'none' | 'patchIfNonempty' export type TusUploadCreationResponsePlan = @@ -1112,6 +1133,82 @@ export function tusUrlStorageKey({ return `${tusUrlStorageFingerprintPrefix({ fingerprint })}${id}` } +function tusAssertUrlStorageRecordPolicySupported(): void { + const policy = TUS_FLOW_POLICY.urlStorage.record + + if (policy.creationTime !== 'sdk-current-date-string') { + throw new Error(`tus: unsupported URL storage creation time policy ${policy.creationTime}`) + } + + if (policy.missingUrl !== 'fail') { + throw new Error(`tus: unsupported URL storage missing URL policy ${policy.missingUrl}`) + } + + if (policy.storedUrlKind !== 'single-or-parallel-upload-url') { + throw new Error(`tus: unsupported URL storage URL kind policy ${policy.storedUrlKind}`) + } +} + +export function tusPlanStoredUploadRecord({ + creationTime, + fingerprint, + metadata, + parallelUploadUrls, + size, + uploadUrl, + useParallelUploadUrls, +}: { + creationTime: string + fingerprint: string + metadata: Record + parallelUploadUrls?: string[] + size: number | null + uploadUrl: string | null + useParallelUploadUrls: boolean +}): TusStoredUploadRecordPlan { + tusAssertUrlStorageRecordPolicySupported() + + if (useParallelUploadUrls) { + if (parallelUploadUrls == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.storageMissingParallelUploadUrls, + reason: 'missingParallelUploadUrls', + } + } + + return { + ok: true, + upload: { + creationTime, + metadata, + parallelUploadUrls, + size, + urlStorageKey: fingerprint, + }, + } + } + + if (uploadUrl == null) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.storageMissingUploadUrl, + reason: 'missingUploadUrl', + } + } + + return { + ok: true, + upload: { + creationTime, + metadata, + size, + uploadUrl, + urlStorageKey: fingerprint, + }, + } +} + export function tusValidateCreateUpload({ hasEndpoint, size, diff --git a/lib/upload.ts b/lib/upload.ts index 43cc46002..6594629cb 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -43,6 +43,7 @@ import { tusPlanResumeUploadRequest, tusPlanRetryAfterError, tusPlanSingleUploadStart, + tusPlanStoredUploadRecord, tusPlanTerminateResponse, tusPlanTerminateUploadRequest, tusPlanUploadChunkRequest, @@ -931,25 +932,22 @@ export class BaseUpload { return } - const storedUpload: PreviousUpload = { - size: this._size, - metadata: this.options.metadata, + const recordPlan = tusPlanStoredUploadRecord({ creationTime: new Date().toString(), - urlStorageKey: storagePlan.fingerprint, - } - - if (this._parallelUploads) { - // Save multiple URLs if the parallelUploads option is used ... - storedUpload.parallelUploadUrls = this._parallelUploadUrls - } else { - // ... otherwise we just save the one available URL. - // @ts-expect-error We still have to figure out the null/undefined situation. - storedUpload.uploadUrl = this.url + fingerprint: storagePlan.fingerprint, + metadata: this.options.metadata, + parallelUploadUrls: this._parallelUploadUrls, + size: this._size, + uploadUrl: this.url, + useParallelUploadUrls: this._parallelUploads != null, + }) + if (!recordPlan.ok) { + throw new Error(recordPlan.message) } const urlStorageKey = await this.options.urlStorage.addUpload( storagePlan.fingerprint, - storedUpload, + recordPlan.upload, ) // TODO: Emit a waring if urlStorageKey is undefined. Should we even allow this? this._urlStorageKey = urlStorageKey From f4758c61be8131a35873188758572dac71d99b8b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 18:39:07 +0200 Subject: [PATCH 053/155] Use generated TUS cleanup policy --- lib/protocol_generated.ts | 61 +++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 21 ++++++++++---- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 3444b63e0..2f4cabbfe 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -178,6 +178,10 @@ export const TUS_UPLOAD_BODY = { } export const TUS_FLOW_POLICY = { + abort: { + removeStoredUrlAfterTermination: 'after-successful-termination', + terminateUpload: 'when-requested-and-upload-url-known', + }, eventHooks: { uploadUrlAvailable: { createUpload: 'after-url-known-before-storage', @@ -324,6 +328,7 @@ export const TUS_FLOW_POLICY = { missingUrl: 'fail', storedUrlKind: 'single-or-parallel-upload-url', }, + removeOnSuccess: 'when-option-enabled', separator: '::', }, } @@ -538,6 +543,10 @@ export type TusRemovedResumeOptionWarningPlan = | { message: string; shouldWarn: true } | { shouldWarn: false } +export type TusAbortTerminationPlan = + | { action: 'skip' } + | { action: 'terminate'; removeStoredUpload: true; uploadUrl: string } + export interface TusLogMessagePlan { message: string } @@ -894,6 +903,40 @@ export function tusDefaultRetryPolicyDecision({ return tusShouldRetryStatus(status) && isOnline } +function tusAssertAbortPolicySupported(): void { + const policy = TUS_FLOW_POLICY.abort + + if (policy.terminateUpload !== 'when-requested-and-upload-url-known') { + throw new Error(`tus: unsupported abort termination policy ${policy.terminateUpload}`) + } + + if (policy.removeStoredUrlAfterTermination !== 'after-successful-termination') { + throw new Error( + `tus: unsupported abort storage cleanup policy ${policy.removeStoredUrlAfterTermination}`, + ) + } +} + +export function tusPlanAbortTermination({ + shouldTerminate, + uploadUrl, +}: { + shouldTerminate: boolean + uploadUrl: string | null +}): TusAbortTerminationPlan { + tusAssertAbortPolicySupported() + + if (!shouldTerminate || uploadUrl == null) { + return { action: 'skip' } + } + + return { + action: 'terminate', + removeStoredUpload: true, + uploadUrl, + } +} + function tusAssertUploadUrlAvailableHookPolicySupported(): void { const policy = TUS_FLOW_POLICY.eventHooks.uploadUrlAvailable @@ -1149,6 +1192,24 @@ function tusAssertUrlStorageRecordPolicySupported(): void { } } +function tusAssertUrlStorageCleanupPolicySupported(): void { + if (TUS_FLOW_POLICY.urlStorage.removeOnSuccess !== 'when-option-enabled') { + throw new Error( + `tus: unsupported URL storage success cleanup policy ${TUS_FLOW_POLICY.urlStorage.removeOnSuccess}`, + ) + } +} + +export function tusShouldRemoveStoredUploadOnSuccess({ + removeFingerprintOnSuccess, +}: { + removeFingerprintOnSuccess: boolean +}): boolean { + tusAssertUrlStorageCleanupPolicySupported() + + return removeFingerprintOnSuccess +} + export function tusPlanStoredUploadRecord({ creationTime, fingerprint, diff --git a/lib/upload.ts b/lib/upload.ts index 6594629cb..9602adeab 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -26,6 +26,7 @@ import { tusGetUploadOffsetRequestPlan, tusNonErrorThrownValueMessage, tusPatchUploadRequestPlan, + tusPlanAbortTermination, tusPlanCreatedUploadLog, tusPlanFinalUploadCreation, tusPlanFingerprint, @@ -58,6 +59,7 @@ import { tusReadUploadOffsetResponse, tusResolveUploadLocation, tusShouldEvaluateRetryPolicy, + tusShouldRemoveStoredUploadOnSuccess, tusShouldSendUploadBodyDuringCreation, tusShouldUseCustomRetryPolicy, tusTerminateUploadRequestPlan, @@ -442,10 +444,15 @@ export class BaseUpload { this._retryTimeout = undefined } - if (shouldTerminate && this.url != null) { - await terminate(this.url, this.options) - // Remove entry from the URL storage since the upload URL is no longer valid. - await this._removeFromUrlStorage() + const abortTerminationPlan = tusPlanAbortTermination({ + shouldTerminate, + uploadUrl: this.url, + }) + if (abortTerminationPlan.action === 'terminate') { + await terminate(abortTerminationPlan.uploadUrl, this.options) + if (abortTerminationPlan.removeStoredUpload) { + await this._removeFromUrlStorage() + } } } @@ -513,7 +520,11 @@ export class BaseUpload { * @api private */ private async _emitSuccess(lastResponse: HttpResponse): Promise { - if (this.options.removeFingerprintOnSuccess) { + if ( + tusShouldRemoveStoredUploadOnSuccess({ + removeFingerprintOnSuccess: this.options.removeFingerprintOnSuccess, + }) + ) { // Remove stored fingerprint and corresponding endpoint. This causes // new uploads of the same file to be treated as a different file. await this._removeFromUrlStorage() From 22b2c2517224cadb3a2d7433fefc5eceb10f3fa4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 19:20:18 +0200 Subject: [PATCH 054/155] Use generated TUS abort sequence --- lib/protocol_generated.ts | 87 +++++++++++++++++++++++++++++++++------ lib/upload.ts | 66 +++++++++++++++++------------ 2 files changed, 115 insertions(+), 38 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 2f4cabbfe..2db48c799 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -180,6 +180,13 @@ export const TUS_UPLOAD_BODY = { export const TUS_FLOW_POLICY = { abort: { removeStoredUrlAfterTermination: 'after-successful-termination', + sequence: [ + 'mark-aborted', + 'abort-parallel-uploads', + 'abort-current-request', + 'clear-retry-timer', + 'terminate-upload-if-requested', + ], terminateUpload: 'when-requested-and-upload-url-known', }, eventHooks: { @@ -543,9 +550,16 @@ export type TusRemovedResumeOptionWarningPlan = | { message: string; shouldWarn: true } | { shouldWarn: false } -export type TusAbortTerminationPlan = - | { action: 'skip' } - | { action: 'terminate'; removeStoredUpload: true; uploadUrl: string } +export type TusAbortRuntimeAction = + | { action: 'abortCurrentRequest' } + | { action: 'abortParallelUploads'; shouldTerminate: boolean } + | { action: 'clearRetryTimer' } + | { action: 'markAborted' } + | { action: 'terminateUpload'; removeStoredUpload: true; uploadUrl: string } + +export interface TusAbortPlan { + actions: TusAbortRuntimeAction[] +} export interface TusLogMessagePlan { message: string @@ -905,6 +919,19 @@ export function tusDefaultRetryPolicyDecision({ function tusAssertAbortPolicySupported(): void { const policy = TUS_FLOW_POLICY.abort + const supportedActions = [ + 'mark-aborted', + 'abort-parallel-uploads', + 'abort-current-request', + 'clear-retry-timer', + 'terminate-upload-if-requested', + ] + + for (const action of policy.sequence) { + if (!supportedActions.includes(action)) { + throw new Error(`tus: unsupported abort sequence action ${action}`) + } + } if (policy.terminateUpload !== 'when-requested-and-upload-url-known') { throw new Error(`tus: unsupported abort termination policy ${policy.terminateUpload}`) @@ -917,24 +944,60 @@ function tusAssertAbortPolicySupported(): void { } } -export function tusPlanAbortTermination({ +export function tusPlanAbort({ + hasCurrentRequest, + hasParallelUploads, + hasRetryTimer, shouldTerminate, uploadUrl, }: { + hasCurrentRequest: boolean + hasParallelUploads: boolean + hasRetryTimer: boolean shouldTerminate: boolean uploadUrl: string | null -}): TusAbortTerminationPlan { +}): TusAbortPlan { tusAssertAbortPolicySupported() - if (!shouldTerminate || uploadUrl == null) { - return { action: 'skip' } - } + const actions: TusAbortRuntimeAction[] = [] + for (const policyAction of TUS_FLOW_POLICY.abort.sequence) { + if (policyAction === 'mark-aborted') { + actions.push({ action: 'markAborted' }) + continue + } - return { - action: 'terminate', - removeStoredUpload: true, - uploadUrl, + if (policyAction === 'abort-parallel-uploads') { + if (hasParallelUploads) { + actions.push({ action: 'abortParallelUploads', shouldTerminate }) + } + continue + } + + if (policyAction === 'abort-current-request') { + if (hasCurrentRequest) { + actions.push({ action: 'abortCurrentRequest' }) + } + continue + } + + if (policyAction === 'clear-retry-timer') { + if (hasRetryTimer) { + actions.push({ action: 'clearRetryTimer' }) + } + continue + } + + if (policyAction === 'terminate-upload-if-requested') { + if (shouldTerminate && uploadUrl != null) { + actions.push({ action: 'terminateUpload', removeStoredUpload: true, uploadUrl }) + } + continue + } + + throw new Error(`tus: unsupported abort sequence action ${policyAction}`) } + + return { actions } } function tusAssertUploadUrlAvailableHookPolicySupported(): void { diff --git a/lib/upload.ts b/lib/upload.ts index 9602adeab..7182eba34 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -26,7 +26,7 @@ import { tusGetUploadOffsetRequestPlan, tusNonErrorThrownValueMessage, tusPatchUploadRequestPlan, - tusPlanAbortTermination, + tusPlanAbort, tusPlanCreatedUploadLog, tusPlanFinalUploadCreation, tusPlanFingerprint, @@ -422,36 +422,50 @@ export class BaseUpload { * @return {Promise} The Promise will be resolved/rejected when the requests finish. */ async abort(shouldTerminate = false): Promise { - // Set the aborted flag before any `await`s, so no new requests are started. - this._aborted = true + const abortPlan = tusPlanAbort({ + hasCurrentRequest: this._req != null, + hasParallelUploads: this._parallelUploads != null, + hasRetryTimer: this._retryTimeout != null, + shouldTerminate, + uploadUrl: this.url, + }) - // Stop any parallel partial uploads, that have been started in _startParallelUploads. - if (this._parallelUploads != null) { - for (const upload of this._parallelUploads) { - await upload.abort(shouldTerminate) + for (const action of abortPlan.actions) { + if (action.action === 'markAborted') { + this._aborted = true + continue } - } - // Stop any current running request. - if (this._req != null) { - await this._req.abort() - // Note: We do not close the file source here, so the user can resume in the future. - } + if (action.action === 'abortParallelUploads') { + if (this._parallelUploads != null) { + for (const upload of this._parallelUploads) { + await upload.abort(action.shouldTerminate) + } + } + continue + } - // Stop any timeout used for initiating a retry. - if (this._retryTimeout != null) { - clearTimeout(this._retryTimeout) - this._retryTimeout = undefined - } + if (action.action === 'abortCurrentRequest') { + if (this._req != null) { + await this._req.abort() + // Note: We do not close the file source here, so the user can resume in the future. + } + continue + } - const abortTerminationPlan = tusPlanAbortTermination({ - shouldTerminate, - uploadUrl: this.url, - }) - if (abortTerminationPlan.action === 'terminate') { - await terminate(abortTerminationPlan.uploadUrl, this.options) - if (abortTerminationPlan.removeStoredUpload) { - await this._removeFromUrlStorage() + if (action.action === 'clearRetryTimer') { + if (this._retryTimeout != null) { + clearTimeout(this._retryTimeout) + this._retryTimeout = undefined + } + continue + } + + if (action.action === 'terminateUpload') { + await terminate(action.uploadUrl, this.options) + if (action.removeStoredUpload) { + await this._removeFromUrlStorage() + } } } } From 2e5a4884abf767ece1f74721aac123860db84ed0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 19:46:44 +0200 Subject: [PATCH 055/155] Use generated TUS HTTP stack policy --- lib/browser/FetchHttpStack.ts | 5 ++- lib/browser/XHRHttpStack.ts | 3 +- lib/node/NodeHttpStack.ts | 21 ++++++++--- lib/protocol_generated.ts | 68 +++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/lib/browser/FetchHttpStack.ts b/lib/browser/FetchHttpStack.ts index 4d7cf333e..24966240d 100644 --- a/lib/browser/FetchHttpStack.ts +++ b/lib/browser/FetchHttpStack.ts @@ -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 { @@ -51,9 +52,7 @@ class FetchRequest implements HttpRequest { async send(body?: SliceType): Promise { 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, { diff --git a/lib/browser/XHRHttpStack.ts b/lib/browser/XHRHttpStack.ts index 396a371b3..f0a39050a 100644 --- a/lib/browser/XHRHttpStack.ts +++ b/lib/browser/XHRHttpStack.ts @@ -6,6 +6,7 @@ import type { HttpStack, SliceType, } from '../options.js' +import { tusHttpStackNodeReadableBodyUnsupportedMessage } from '../protocol_generated.js' export class XHRHttpStack implements HttpStack { createRequest(method: string, url: string): HttpRequest { @@ -68,7 +69,7 @@ class XHRRequest implements HttpRequest { send(body?: SliceType): Promise { 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' }), ) } diff --git a/lib/node/NodeHttpStack.ts b/lib/node/NodeHttpStack.ts index 6a5fb0a47..8ebdb53c6 100644 --- a/lib/node/NodeHttpStack.ts +++ b/lib/node/NodeHttpStack.ts @@ -10,6 +10,10 @@ import type { HttpStack, SliceType, } from '../options.js' +import { + tusNodeHttpStackMissingStatusCodeMessage, + tusNodeHttpStackUnsupportedBodyTypeMessage, +} from '../protocol_generated.js' export class NodeHttpStack implements HttpStack { private _requestOptions: http.RequestOptions @@ -80,9 +84,10 @@ class Request implements HttpRequest { nodeBody = body } else { throw new Error( - // @ts-expect-error According to the types, this case cannot happen. But - // we still want to try logging the constructor if this code is reached by accident. - `Unsupported HTTP request body type in Node.js HTTP stack: ${typeof body} (constructor: ${body?.constructor?.name})`, + tusNodeHttpStackUnsupportedBodyTypeMessage({ + bodyType: typeof body, + constructorName: getConstructorName(body), + }), ) } } @@ -166,7 +171,7 @@ class Response implements HttpResponse { getStatus() { if (this._response.statusCode === undefined) { - throw new Error('no status code available yet') + throw new Error(tusNodeHttpStackMissingStatusCodeMessage()) } return this._response.statusCode } @@ -188,6 +193,14 @@ class Response implements HttpResponse { } } +function getConstructorName(value: unknown): string { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return 'undefined' + } + + return value.constructor.name +} + // ProgressEmitter is a simple PassThrough-style transform stream which keeps // track of the number of bytes which have been piped through it and will // invoke the `onprogress` function whenever new number are available. diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 2db48c799..a2fcb0dbc 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -222,6 +222,18 @@ export const TUS_FLOW_POLICY = { }, nodeExtraTypes: ['fs.ReadStream (Node.js)', 'stream.Readable (Node.js)'], }, + httpStacks: { + browserStackNodeReadableBody: 'unsupported', + messages: { + browserStackNodeReadableBodyUnsupported: + 'Using a Node.js readable stream as HTTP request body is not supported using the {stackName} HTTP stack.', + nodeStackMissingStatusCode: 'no status code available yet', + nodeStackUnsupportedBodyType: + 'Unsupported HTTP request body type in Node.js HTTP stack: {bodyType} (constructor: {constructorName})', + }, + nodeStackMissingStatusCode: 'throw', + nodeStackUnsupportedBodyType: 'throw', + }, locationResolution: { strategy: 'relative-to-creation-request-url', }, @@ -1108,6 +1120,62 @@ export function tusNodeStreamStartOutsideBufferMessage(): string { return TUS_FLOW_POLICY.fileSources.messages.nodeStreamStartOutsideBuffer } +function tusAssertHttpStackPolicySupported(): void { + const policy = TUS_FLOW_POLICY.httpStacks + + if (policy.browserStackNodeReadableBody !== 'unsupported') { + throw new Error( + `tus: unsupported browser HTTP stack Node readable body policy ${policy.browserStackNodeReadableBody}`, + ) + } + + if (policy.nodeStackUnsupportedBodyType !== 'throw') { + throw new Error( + `tus: unsupported Node HTTP stack body type policy ${policy.nodeStackUnsupportedBodyType}`, + ) + } + + if (policy.nodeStackMissingStatusCode !== 'throw') { + throw new Error( + `tus: unsupported Node HTTP stack status code policy ${policy.nodeStackMissingStatusCode}`, + ) + } +} + +export function tusHttpStackNodeReadableBodyUnsupportedMessage({ + stackName, +}: { + stackName: string +}): string { + tusAssertHttpStackPolicySupported() + + return tusFormatFlowMessage( + TUS_FLOW_POLICY.httpStacks.messages.browserStackNodeReadableBodyUnsupported, + { stackName }, + ) +} + +export function tusNodeHttpStackUnsupportedBodyTypeMessage({ + bodyType, + constructorName, +}: { + bodyType: string + constructorName: string +}): string { + tusAssertHttpStackPolicySupported() + + return tusFormatFlowMessage(TUS_FLOW_POLICY.httpStacks.messages.nodeStackUnsupportedBodyType, { + bodyType, + constructorName, + }) +} + +export function tusNodeHttpStackMissingStatusCodeMessage(): string { + tusAssertHttpStackPolicySupported() + + return TUS_FLOW_POLICY.httpStacks.messages.nodeStackMissingStatusCode +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, From af2129a67a2957c3c2162970bb716d750bf015d4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 19:56:31 +0200 Subject: [PATCH 056/155] Use generated TUS URL storage policy --- lib/browser/urlStorage.ts | 23 +++++++++------ lib/node/FileUrlStorage.ts | 3 +- lib/protocol_generated.ts | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/lib/browser/urlStorage.ts b/lib/browser/urlStorage.ts index ac17931a7..135bbeef5 100644 --- a/lib/browser/urlStorage.ts +++ b/lib/browser/urlStorage.ts @@ -1,8 +1,14 @@ import type { PreviousUpload, UrlStorage } from '../options.js' import { + tusIsWebStorageUnavailableError, + tusShouldIgnoreMalformedStoredUpload, tusUrlStorageAllUploadsPrefix, tusUrlStorageFingerprintPrefix, + tusUrlStorageId, tusUrlStorageKey, + tusUrlStorageMissingItemMessage, + tusUrlStorageMissingKeyMessage, + tusWebStorageProbeKey, } from '../protocol_generated.js' let hasStorage = false @@ -14,7 +20,7 @@ try { // Mode on Safari on iOS (see #49) // If the key was not used before, we remove it from local storage again to // not cause confusion where the entry came from. - const key = 'tusSupport' + const key = tusWebStorageProbeKey() const originalValue = localStorage.getItem(key) localStorage.setItem(key, String(originalValue)) if (originalValue == null) localStorage.removeItem(key) @@ -22,8 +28,7 @@ try { // If we try to access localStorage inside a sandboxed iframe, a SecurityError // is thrown. When in private mode on iOS Safari, a QuotaExceededError is // thrown (see #49) - // TODO: Replace `code` with `name` - if (e instanceof DOMException && (e.code === e.SECURITY_ERR || e.code === e.QUOTA_EXCEEDED_ERR)) { + if (e instanceof DOMException && tusIsWebStorageUnavailableError({ domExceptionName: e.name })) { hasStorage = false } else { throw e @@ -49,7 +54,7 @@ export class WebStorageUrlStorage implements UrlStorage { } addUpload(fingerprint: string, upload: PreviousUpload): Promise { - const id = Math.round(Math.random() * 1e12) + const id = tusUrlStorageId({ randomValue: Math.random() }) const key = tusUrlStorageKey({ fingerprint, id }) localStorage.setItem(key, JSON.stringify(upload)) @@ -62,7 +67,7 @@ export class WebStorageUrlStorage implements UrlStorage { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) if (key == null) { - throw new Error(`didn't find key for item ${i}`) + throw new Error(tusUrlStorageMissingKeyMessage({ index: i })) } // Ignore entires that are not from tus-js-client @@ -70,18 +75,20 @@ export class WebStorageUrlStorage implements UrlStorage { const item = localStorage.getItem(key) if (item == null) { - throw new Error(`didn't find item for key ${key}`) + throw new Error(tusUrlStorageMissingItemMessage({ key })) } try { - // TODO: Validate JSON const upload = JSON.parse(item) upload.urlStorageKey = key results.push(upload) - } catch (_e) { + } catch (err) { // The JSON parse error is intentionally ignored here, so a malformed // entry in the storage cannot prevent an upload. + if (!tusShouldIgnoreMalformedStoredUpload()) { + throw err + } } } diff --git a/lib/node/FileUrlStorage.ts b/lib/node/FileUrlStorage.ts index 59bf184a0..487ef83fa 100644 --- a/lib/node/FileUrlStorage.ts +++ b/lib/node/FileUrlStorage.ts @@ -4,6 +4,7 @@ import type { PreviousUpload, UrlStorage } from '../options.js' import { tusUrlStorageAllUploadsPrefix, tusUrlStorageFingerprintPrefix, + tusUrlStorageId, tusUrlStorageKey, } from '../protocol_generated.js' @@ -29,7 +30,7 @@ export class FileUrlStorage implements UrlStorage { } async addUpload(fingerprint: string, upload: PreviousUpload): Promise { - const id = Math.round(Math.random() * 1e12) + const id = tusUrlStorageId({ randomValue: Math.random() }) const key = tusUrlStorageKey({ fingerprint, id }) await this._setItem(key, upload) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index a2fcb0dbc..c32a17d3a 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -341,6 +341,14 @@ export const TUS_FLOW_POLICY = { }, }, urlStorage: { + id: { + multiplier: 1000000000000, + strategy: 'rounded-random-number', + }, + messages: { + missingItem: "didn't find item for key {key}", + missingKey: "didn't find key for item {index}", + }, namespace: 'tus', record: { creationTime: 'sdk-current-date-string', @@ -349,6 +357,11 @@ export const TUS_FLOW_POLICY = { }, removeOnSuccess: 'when-option-enabled', separator: '::', + webStorage: { + malformedEntry: 'ignore', + probeKey: 'tusSupport', + unavailableDomExceptionNames: ['QuotaExceededError', 'SecurityError'], + }, }, } @@ -1307,6 +1320,50 @@ export function tusUrlStorageKey({ return `${tusUrlStorageFingerprintPrefix({ fingerprint })}${id}` } +export function tusUrlStorageId({ randomValue }: { randomValue: number }): number { + const policy = TUS_FLOW_POLICY.urlStorage.id + if (policy.strategy !== 'rounded-random-number') { + throw new Error(`tus: unsupported URL storage ID policy ${policy.strategy}`) + } + + return Math.round(randomValue * policy.multiplier) +} + +export function tusWebStorageProbeKey(): string { + return TUS_FLOW_POLICY.urlStorage.webStorage.probeKey +} + +export function tusIsWebStorageUnavailableError({ + domExceptionName, +}: { + domExceptionName: string +}): boolean { + return TUS_FLOW_POLICY.urlStorage.webStorage.unavailableDomExceptionNames.includes( + domExceptionName, + ) +} + +export function tusShouldIgnoreMalformedStoredUpload(): boolean { + const policy = TUS_FLOW_POLICY.urlStorage.webStorage.malformedEntry + if (policy === 'ignore') { + return true + } + + throw new Error(`tus: unsupported malformed stored upload policy ${policy}`) +} + +export function tusUrlStorageMissingKeyMessage({ index }: { index: number }): string { + return tusFormatFlowMessage(TUS_FLOW_POLICY.urlStorage.messages.missingKey, { + index, + }) +} + +export function tusUrlStorageMissingItemMessage({ key }: { key: string }): string { + return tusFormatFlowMessage(TUS_FLOW_POLICY.urlStorage.messages.missingItem, { + key, + }) +} + function tusAssertUrlStorageRecordPolicySupported(): void { const policy = TUS_FLOW_POLICY.urlStorage.record From ed31119cfe61cd8caf3b8b08a374e5477d84836d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:01:35 +0200 Subject: [PATCH 057/155] Use generated TUS abort error descriptor --- lib/browser/XHRHttpStack.ts | 8 ++++++-- lib/node/NodeHttpStack.ts | 7 +++++-- lib/protocol_generated.ts | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/browser/XHRHttpStack.ts b/lib/browser/XHRHttpStack.ts index f0a39050a..6e1bbcb89 100644 --- a/lib/browser/XHRHttpStack.ts +++ b/lib/browser/XHRHttpStack.ts @@ -6,7 +6,10 @@ import type { HttpStack, SliceType, } from '../options.js' -import { tusHttpStackNodeReadableBodyUnsupportedMessage } from '../protocol_generated.js' +import { + tusAbortErrorDescriptor, + tusHttpStackNodeReadableBodyUnsupportedMessage, +} from '../protocol_generated.js' export class XHRHttpStack implements HttpStack { createRequest(method: string, url: string): HttpRequest { @@ -83,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) diff --git a/lib/node/NodeHttpStack.ts b/lib/node/NodeHttpStack.ts index 8ebdb53c6..439167192 100644 --- a/lib/node/NodeHttpStack.ts +++ b/lib/node/NodeHttpStack.ts @@ -11,6 +11,7 @@ import type { SliceType, } from '../options.js' import { + tusAbortErrorDescriptor, tusNodeHttpStackMissingStatusCodeMessage, tusNodeHttpStackUnsupportedBodyTypeMessage, } from '../protocol_generated.js' @@ -149,8 +150,10 @@ class Request implements HttpRequest { abort() { // Note: The destroy() method will trigger an `error` event with the provided error. - if (this._request != null) - this._request.destroy(new DOMException('Request was aborted', 'AbortError')) + if (this._request != null) { + const error = tusAbortErrorDescriptor() + this._request.destroy(new DOMException(error.message, error.name)) + } return Promise.resolve() } diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index c32a17d3a..f743892ad 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -179,6 +179,11 @@ export const TUS_UPLOAD_BODY = { export const TUS_FLOW_POLICY = { abort: { + error: { + message: 'Request was aborted', + name: 'AbortError', + type: 'DOMException', + }, removeStoredUrlAfterTermination: 'after-successful-termination', sequence: [ 'mark-aborted', @@ -586,6 +591,12 @@ export interface TusAbortPlan { actions: TusAbortRuntimeAction[] } +export interface TusAbortErrorDescriptor { + message: string + name: string + type: 'DOMException' +} + export interface TusLogMessagePlan { message: string } @@ -967,6 +978,20 @@ function tusAssertAbortPolicySupported(): void { `tus: unsupported abort storage cleanup policy ${policy.removeStoredUrlAfterTermination}`, ) } + + if (policy.error.type !== 'DOMException') { + throw new Error(`tus: unsupported abort error type ${policy.error.type}`) + } +} + +export function tusAbortErrorDescriptor(): TusAbortErrorDescriptor { + tusAssertAbortPolicySupported() + + return { + message: TUS_FLOW_POLICY.abort.error.message, + name: TUS_FLOW_POLICY.abort.error.name, + type: 'DOMException', + } } export function tusPlanAbort({ From 0463d54af15b6edcc3787831e7b6daa867ba50da Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:07:31 +0200 Subject: [PATCH 058/155] Use generated TUS fingerprint policy --- lib/browser/fileSignature.ts | 52 +++++---- lib/node/fileSignature.ts | 31 ++++-- lib/protocol_generated.ts | 203 +++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 37 deletions(-) diff --git a/lib/browser/fileSignature.ts b/lib/browser/fileSignature.ts index 276b8f5f6..5e2c6c412 100644 --- a/lib/browser/fileSignature.ts +++ b/lib/browser/fileSignature.ts @@ -1,4 +1,9 @@ -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' /** @@ -6,37 +11,30 @@ import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReact */ 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()) } diff --git a/lib/node/fileSignature.ts b/lib/node/fileSignature.ts index 8dff27dec..7b9d899c2 100644 --- a/lib/node/fileSignature.ts +++ b/lib/node/fileSignature.ts @@ -3,28 +3,39 @@ import { ReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import * as path from 'node:path' import type { UploadInput, UploadOptions } from '../options.js' +import { + tusNodeBufferFingerprint, + tusNodeFileFingerprint, + tusPlanNodeBufferFingerprint, + tusUnsupportedInputFingerprint, +} from '../protocol_generated.js' export async function fingerprint( file: UploadInput, options: UploadOptions, ): Promise { if (Buffer.isBuffer(file)) { - // create MD5 hash for buffer type - const blockSize = 64 * 1024 // 64kb - const content = file.slice(0, Math.min(blockSize, file.length)) - const hash = createHash('md5').update(content).digest('hex') - const ret = ['node-buffer', hash, file.length, options.endpoint].join('-') - return ret + const plan = tusPlanNodeBufferFingerprint({ size: file.length }) + const content = file.slice(0, plan.sampleBytes) + const hash = createHash(plan.hashAlgorithm).update(content).digest('hex') + return tusNodeBufferFingerprint({ + contentHash: hash, + endpoint: options.endpoint, + size: file.length, + }) } if (file instanceof ReadStream && file.path != null) { const name = path.resolve(Buffer.isBuffer(file.path) ? file.path.toString('utf-8') : file.path) const info = await stat(file.path) - const ret = ['node-file', name, info.size, info.mtime.getTime(), options.endpoint].join('-') - return ret + return tusNodeFileFingerprint({ + absolutePath: name, + endpoint: options.endpoint, + mtimeMs: info.mtime.getTime(), + size: info.size, + }) } - // fingerprint cannot be computed for file input type - return null + return tusUnsupportedInputFingerprint() } diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index f743892ad..be893fe0c 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -227,6 +227,36 @@ export const TUS_FLOW_POLICY = { }, nodeExtraTypes: ['fs.ReadStream (Node.js)', 'stream.Readable (Node.js)'], }, + fingerprints: { + browserBlob: { + fields: ['prefix', 'name', 'type', 'size', 'lastModified', 'endpoint'], + prefix: 'tus-br', + separator: '-', + }, + nodeBuffer: { + fields: ['prefix', 'contentHash', 'size', 'endpoint'], + hashAlgorithm: 'md5', + prefix: 'node-buffer', + sampleBytes: 65536, + separator: '-', + }, + nodeFile: { + fields: ['prefix', 'absolutePath', 'size', 'mtimeMs', 'endpoint'], + path: 'absolute', + prefix: 'node-file', + separator: '-', + }, + reactNative: { + emptyName: 'noname', + emptySize: 'nosize', + exifHash: 'javascript-string-hash-code', + fields: ['prefix', 'name', 'size', 'exifHash', 'endpoint'], + noExif: 'noexif', + prefix: 'tus-rn', + separator: '/', + }, + unsupportedInput: 'null', + }, httpStacks: { browserStackNodeReadableBody: 'unsupported', messages: { @@ -457,6 +487,39 @@ export type TusFingerprintPlan = | { ok: false; message: string; reason: 'missingFingerprint' } | { fingerprint: string; ok: true } +export interface TusBrowserBlobFingerprintInput { + endpoint: string | undefined + lastModified: number | string | undefined + name: string | undefined + size: number | string | undefined + type: string | undefined +} + +export interface TusReactNativeFingerprintInput { + endpoint: string | undefined + exifJson: string | null + name: string | undefined + size: string | undefined +} + +export interface TusNodeBufferFingerprintPlan { + hashAlgorithm: 'md5' + sampleBytes: number +} + +export interface TusNodeBufferFingerprintInput { + contentHash: string + endpoint: string | undefined + size: number +} + +export interface TusNodeFileFingerprintInput { + absolutePath: string + endpoint: string | undefined + mtimeMs: number + size: number +} + export type TusParallelUploadSlicePlan = | { ok: false; message: string; reason: 'missingValue' } | { ok: true } @@ -1276,6 +1339,146 @@ export function tusPlanFingerprint({ return { fingerprint, ok: true } } +function tusFingerprintPart(value: number | string | null | undefined): string { + return value == null ? '' : String(value) +} + +function tusAssertFingerprintFields({ + actualFields, + expectedFields, + policyName, +}: { + actualFields: readonly string[] + expectedFields: readonly string[] + policyName: string +}): void { + if (actualFields.join('|') !== expectedFields.join('|')) { + throw new Error(`tus: unsupported ${policyName} fingerprint fields ${actualFields.join('|')}`) + } +} + +export function tusBrowserBlobFingerprint({ + endpoint, + lastModified, + name, + size, + type, +}: TusBrowserBlobFingerprintInput): string { + const policy = TUS_FLOW_POLICY.fingerprints.browserBlob + tusAssertFingerprintFields({ + actualFields: policy.fields, + expectedFields: ['prefix', 'name', 'type', 'size', 'lastModified', 'endpoint'], + policyName: 'browser Blob', + }) + + return [policy.prefix, name, type, size, lastModified, endpoint] + .map(tusFingerprintPart) + .join(policy.separator) +} + +export function tusReactNativeExifHash({ exifJson }: { exifJson: string }): number { + if (TUS_FLOW_POLICY.fingerprints.reactNative.exifHash !== 'javascript-string-hash-code') { + throw new Error( + `tus: unsupported React Native EXIF hash policy ${TUS_FLOW_POLICY.fingerprints.reactNative.exifHash}`, + ) + } + + let hash = 0 + for (let index = 0; index < exifJson.length; index += 1) { + hash = (hash << 5) - hash + exifJson.charCodeAt(index) + hash |= 0 + } + + return hash +} + +export function tusReactNativeFingerprint({ + endpoint, + exifJson, + name, + size, +}: TusReactNativeFingerprintInput): string { + const policy = TUS_FLOW_POLICY.fingerprints.reactNative + tusAssertFingerprintFields({ + actualFields: policy.fields, + expectedFields: ['prefix', 'name', 'size', 'exifHash', 'endpoint'], + policyName: 'React Native', + }) + + return [ + policy.prefix, + name || policy.emptyName, + size || policy.emptySize, + exifJson == null ? policy.noExif : tusReactNativeExifHash({ exifJson }), + endpoint, + ] + .map(tusFingerprintPart) + .join(policy.separator) +} + +export function tusPlanNodeBufferFingerprint({ + size, +}: { + size: number +}): TusNodeBufferFingerprintPlan { + const policy = TUS_FLOW_POLICY.fingerprints.nodeBuffer + tusAssertFingerprintFields({ + actualFields: policy.fields, + expectedFields: ['prefix', 'contentHash', 'size', 'endpoint'], + policyName: 'Node buffer', + }) + + if (policy.hashAlgorithm !== 'md5') { + throw new Error(`tus: unsupported Node buffer fingerprint hash ${policy.hashAlgorithm}`) + } + + return { + hashAlgorithm: policy.hashAlgorithm, + sampleBytes: Math.min(policy.sampleBytes, size), + } +} + +export function tusNodeBufferFingerprint({ + contentHash, + endpoint, + size, +}: TusNodeBufferFingerprintInput): string { + const policy = TUS_FLOW_POLICY.fingerprints.nodeBuffer + return [policy.prefix, contentHash, size, endpoint].map(tusFingerprintPart).join(policy.separator) +} + +export function tusNodeFileFingerprint({ + absolutePath, + endpoint, + mtimeMs, + size, +}: TusNodeFileFingerprintInput): string { + const policy = TUS_FLOW_POLICY.fingerprints.nodeFile + tusAssertFingerprintFields({ + actualFields: policy.fields, + expectedFields: ['prefix', 'absolutePath', 'size', 'mtimeMs', 'endpoint'], + policyName: 'Node file', + }) + + if (policy.path !== 'absolute') { + throw new Error(`tus: unsupported Node file fingerprint path policy ${policy.path}`) + } + + return [policy.prefix, absolutePath, size, mtimeMs, endpoint] + .map(tusFingerprintPart) + .join(policy.separator) +} + +export function tusUnsupportedInputFingerprint(): null { + if (TUS_FLOW_POLICY.fingerprints.unsupportedInput !== 'null') { + throw new Error( + `tus: unsupported fallback fingerprint policy ${TUS_FLOW_POLICY.fingerprints.unsupportedInput}`, + ) + } + + return null +} + export function tusPlanParallelUploadSlice({ hasValue, }: { From fcb31069f2d5c135c144004d0ef84edbf2972e40 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:17:40 +0200 Subject: [PATCH 059/155] Use generated TUS progress throttle policy --- lib/node/NodeHttpStack.ts | 15 +++++++++------ lib/protocol_generated.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/lib/node/NodeHttpStack.ts b/lib/node/NodeHttpStack.ts index 439167192..e0638b2ac 100644 --- a/lib/node/NodeHttpStack.ts +++ b/lib/node/NodeHttpStack.ts @@ -12,6 +12,7 @@ import type { } from '../options.js' import { tusAbortErrorDescriptor, + tusHttpStackProgressThrottle, tusNodeHttpStackMissingStatusCodeMessage, tusNodeHttpStackUnsupportedBodyTypeMessage, } from '../protocol_generated.js' @@ -220,9 +221,10 @@ class ProgressEmitter extends Transform { // these calls can occur frequently, especially when you have a good // connection to the remote server. Therefore, we are throtteling them to // prevent accessive function calls. - this._onprogress = throttle(onprogress, 100, { - leading: true, - trailing: false, + const throttlePolicy = tusHttpStackProgressThrottle() + this._onprogress = throttle(onprogress, throttlePolicy.milliseconds, { + leading: throttlePolicy.leading, + trailing: throttlePolicy.trailing, }) } @@ -251,9 +253,10 @@ function writeBufferToStreamWithProgress( source: Uint8Array, onprogress: HttpProgressHandler, ) { - onprogress = throttle(onprogress, 100, { - leading: true, - trailing: false, + const throttlePolicy = tusHttpStackProgressThrottle() + onprogress = throttle(onprogress, throttlePolicy.milliseconds, { + leading: throttlePolicy.leading, + trailing: throttlePolicy.trailing, }) let offset = 0 diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index be893fe0c..bdef3aeca 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -267,6 +267,11 @@ export const TUS_FLOW_POLICY = { 'Unsupported HTTP request body type in Node.js HTTP stack: {bodyType} (constructor: {constructorName})', }, nodeStackMissingStatusCode: 'throw', + progressThrottle: { + leading: true, + milliseconds: 100, + trailing: false, + }, nodeStackUnsupportedBodyType: 'throw', }, locationResolution: { @@ -698,6 +703,12 @@ export type TusFileSourceChunkSizeValidationResult = | { chunkSize: number; ok: true } | { message: string; ok: false; reason: 'missingFiniteChunkSize' } +export interface TusHttpStackProgressThrottle { + leading: boolean + milliseconds: number + trailing: boolean +} + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -1241,6 +1252,12 @@ function tusAssertHttpStackPolicySupported(): void { `tus: unsupported Node HTTP stack status code policy ${policy.nodeStackMissingStatusCode}`, ) } + + if (!Number.isFinite(policy.progressThrottle.milliseconds)) { + throw new Error( + `tus: unsupported HTTP progress throttle ${policy.progressThrottle.milliseconds}`, + ) + } } export function tusHttpStackNodeReadableBodyUnsupportedMessage({ @@ -1277,6 +1294,16 @@ export function tusNodeHttpStackMissingStatusCodeMessage(): string { return TUS_FLOW_POLICY.httpStacks.messages.nodeStackMissingStatusCode } +export function tusHttpStackProgressThrottle(): TusHttpStackProgressThrottle { + tusAssertHttpStackPolicySupported() + + return { + leading: TUS_FLOW_POLICY.httpStacks.progressThrottle.leading, + milliseconds: TUS_FLOW_POLICY.httpStacks.progressThrottle.milliseconds, + trailing: TUS_FLOW_POLICY.httpStacks.progressThrottle.trailing, + } +} + export function tusPlanSingleUploadStart({ currentUrl, uploadUrl, From 989045589d0661e1c05e1258a460f2acc7d88d7f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:26:17 +0200 Subject: [PATCH 060/155] Use generated TUS detailed error formatting --- lib/DetailedError.ts | 35 ++++++++++++++++++--------- lib/protocol_generated.ts | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/lib/DetailedError.ts b/lib/DetailedError.ts index c7fe38412..cc6381b9f 100644 --- a/lib/DetailedError.ts +++ b/lib/DetailedError.ts @@ -1,5 +1,11 @@ import type { HttpRequest, HttpResponse } from './options.js' -import { TUS_REQUEST_ID_HEADER_NAME } from './protocol_generated.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 @@ -15,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(TUS_REQUEST_ID_HEADER_NAME) || '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, + }) } } diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index bdef3aeca..a9ed8d506 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -194,6 +194,13 @@ export const TUS_FLOW_POLICY = { ], terminateUpload: 'when-requested-and-upload-url-known', }, + detailedErrors: { + causedByTemplate: ', caused by {cause}', + emptyResponseBody: '', + missingValue: 'n/a', + requestContextTemplate: + ', originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})', + }, eventHooks: { uploadUrlAvailable: { createUpload: 'after-url-known-before-storage', @@ -709,6 +716,14 @@ export interface TusHttpStackProgressThrottle { trailing: boolean } +export interface TusDetailedErrorRequestContext { + body: number | string + method: string + requestId: number | string + status: number | string + url: string +} + function tusFormatFlowMessage(template: string, values: Record): string { let message = template for (const [name, value] of Object.entries(values)) { @@ -718,6 +733,42 @@ function tusFormatFlowMessage(template: string, values: Record Date: Thu, 28 May 2026 20:40:31 +0200 Subject: [PATCH 061/155] Use generated TUS Cordova file source error --- lib/cordova/readAsByteArray.ts | 8 +++++++- lib/protocol_generated.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/cordova/readAsByteArray.ts b/lib/cordova/readAsByteArray.ts index 9f7e33832..d14c0a53c 100644 --- a/lib/cordova/readAsByteArray.ts +++ b/lib/cordova/readAsByteArray.ts @@ -1,3 +1,5 @@ +import { tusCordovaInvalidArrayBufferResultMessage } from '../protocol_generated.js' + /** * readAsByteArray converts a File/Blob object to a Uint8Array. * This function is only used on the Apache Cordova platform. @@ -10,7 +12,11 @@ export function readAsByteArray(chunk: Blob): Promise { const reader = new FileReader() reader.onload = () => { if (!(reader.result instanceof ArrayBuffer)) { - reject(new Error(`invalid result types for readAsArrayBuffer: ${typeof reader.result}`)) + reject( + new Error( + tusCordovaInvalidArrayBufferResultMessage({ resultType: typeof reader.result }), + ), + ) return } const value = new Uint8Array(reader.result) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index a9ed8d506..5aa54ba46 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -218,6 +218,7 @@ export const TUS_FLOW_POLICY = { 'ReadableStream (Web Streams)', ], messages: { + cordovaInvalidArrayBufferResult: 'invalid result types for readAsArrayBuffer: {resultType}', nodeStreamBackwardsRead: 'cannot slice from position which we already seeked away', nodeStreamChunkSizeRequired: 'cannot create source for stream without a finite value for the `chunkSize` option; specify a chunkSize to control the memory consumption', @@ -1283,6 +1284,19 @@ export function tusNodeStreamStartOutsideBufferMessage(): string { return TUS_FLOW_POLICY.fileSources.messages.nodeStreamStartOutsideBuffer } +export function tusCordovaInvalidArrayBufferResultMessage({ + resultType, +}: { + resultType: string +}): string { + return tusFormatFlowMessage( + TUS_FLOW_POLICY.fileSources.messages.cordovaInvalidArrayBufferResult, + { + resultType, + }, + ) +} + function tusAssertHttpStackPolicySupported(): void { const policy = TUS_FLOW_POLICY.httpStacks From a4461309f2c664dbb2d950aa7bdf144777af2518 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:45:25 +0200 Subject: [PATCH 062/155] Use generated TUS retry online policy --- lib/protocol_generated.ts | 25 ++++++++++++++++++++++--- lib/upload.ts | 9 +++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 5aa54ba46..14d25861e 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -384,7 +384,11 @@ export const TUS_FLOW_POLICY = { customDecision: 'custom-callback-before-default-decision', defaultDecision: 'retryable-status-and-online', evaluationTrigger: 'generated-plan-evaluate-policy', - onlineSignal: 'sdk-platform-online-status', + onlineSignal: { + defaultWhenUnavailable: true, + offlineWhenPlatformOnlineIsFalse: true, + source: 'sdk-platform-online-status', + }, timer: 'sdk-platform-timer', }, }, @@ -1021,8 +1025,8 @@ function tusAssertRequestLifecyclePolicySupported(): void { throw new Error(`tus: unsupported default retry decision ${policy.retry.defaultDecision}`) } - if (policy.retry.onlineSignal !== 'sdk-platform-online-status') { - throw new Error(`tus: unsupported retry online signal ${policy.retry.onlineSignal}`) + if (policy.retry.onlineSignal.source !== 'sdk-platform-online-status') { + throw new Error(`tus: unsupported retry online signal ${policy.retry.onlineSignal.source}`) } if (policy.retry.timer !== 'sdk-platform-timer') { @@ -1030,6 +1034,21 @@ function tusAssertRequestLifecyclePolicySupported(): void { } } +export function tusDefaultRetryOnlineStatus({ + platformOnline, +}: { + platformOnline: boolean | undefined +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + const policy = TUS_FLOW_POLICY.requestLifecycle.retry.onlineSignal + if (platformOnline === false && policy.offlineWhenPlatformOnlineIsFalse) { + return false + } + + return policy.defaultWhenUnavailable +} + export function tusPlanRequestLifecycleHooks({ hasAfterResponseHook, hasBeforeRequestHook, diff --git a/lib/upload.ts b/lib/upload.ts index 7182eba34..ec3b74afa 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -20,6 +20,7 @@ import { tusChunkEnd, tusCreateUploadRequestPlan, tusDefaultClientOptions, + tusDefaultRetryOnlineStatus, tusDefaultRetryPolicyDecision, tusDeferredUploadLengthPlan, tusFinalUploadRequestPlan, @@ -1059,15 +1060,11 @@ async function sendRequest( * @api private */ function isOnline(): boolean { - let online = true // Note: We don't reference `window` here because the navigator object also exists // in a Web Worker's context. // -disable-next-line no-undef - if (typeof navigator !== 'undefined' && navigator.onLine === false) { - online = false - } - - return online + const platformOnline = typeof navigator !== 'undefined' ? navigator.onLine : undefined + return tusDefaultRetryOnlineStatus({ platformOnline }) } /** From 681a81e7c13757f0974b9d43172e7dc1dda81d45 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:50:59 +0200 Subject: [PATCH 063/155] Use generated TUS metadata encoding --- lib/protocol_generated.ts | 40 ++++++++++++++++++--------------------- lib/upload.ts | 7 ------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 14d25861e..40b622950 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -2,6 +2,8 @@ // please report the issue instead of editing this file by hand; the source fix // belongs in the protocol contract generator so all TUS clients stay in sync. +import { Base64 } from 'js-base64' + export const TUS_DEFAULT_PROTOCOL_VERSION = '1.0.0' export const TUS_DEFAULT_CLIENT_PROTOCOL = 'tus-v1' @@ -2495,23 +2497,27 @@ export function tusFinalUploadConcatValue(uploadUrls: readonly string[]): string return `${TUS_CONCATENATION.finalPrefix}${uploadUrls.join(TUS_CONCATENATION.uploadUrlSeparator)}` } -export function tusEncodeMetadata( - metadata: Record, - encodeMetadataValue: (value: string) => string, -): string { +export function tusEncodeMetadataValue(value: string): string { + if (TUS_METADATA_ENCODING.valueEncoding !== 'base64') { + throw new Error( + `tus: unsupported metadata value encoding ${TUS_METADATA_ENCODING.valueEncoding}`, + ) + } + + return Base64.encode(value) +} + +export function tusEncodeMetadata(metadata: Record): string { return Object.entries(metadata) .map( ([key, value]) => - `${key}${TUS_METADATA_ENCODING.keyValueSeparator}${encodeMetadataValue(String(value))}`, + `${key}${TUS_METADATA_ENCODING.keyValueSeparator}${tusEncodeMetadataValue(String(value))}`, ) .join(TUS_METADATA_ENCODING.entrySeparator) } -export function tusMetadataHeaders( - metadata: Record, - encodeMetadataValue: (value: string) => string, -): Record { - const encodedMetadata = tusEncodeMetadata(metadata, encodeMetadataValue) +export function tusMetadataHeaders(metadata: Record): Record { + const encodedMetadata = tusEncodeMetadata(metadata) if (encodedMetadata === '') { return {} } @@ -2522,12 +2528,10 @@ export function tusMetadataHeaders( } export function tusCreateUploadHeaders({ - encodeMetadataValue, metadata, size, uploadLengthDeferred, }: { - encodeMetadataValue: (value: string) => string metadata: Record size: number | null uploadLengthDeferred: boolean @@ -2538,7 +2542,7 @@ export function tusCreateUploadHeaders({ : size == null ? {} : { [TUS_HEADERS.UPLOAD_LENGTH]: `${size}` }), - ...tusMetadataHeaders(metadata, encodeMetadataValue), + ...tusMetadataHeaders(metadata), } } @@ -2562,17 +2566,15 @@ export function tusUploadLengthHeaders({ size }: { size: number }): Record string metadata: Record uploadUrls: readonly string[] }): Record { return { [TUS_HEADERS.UPLOAD_CONCAT]: tusFinalUploadConcatValue(uploadUrls), - ...tusMetadataHeaders(metadata, encodeMetadataValue), + ...tusMetadataHeaders(metadata), } } @@ -2606,7 +2608,6 @@ export function tusUploadBodyHeaders({ } export function tusCreateUploadRequestPlan({ - encodeMetadataValue, endpoint, metadata, protocol, @@ -2614,7 +2615,6 @@ export function tusCreateUploadRequestPlan({ uploadComplete, uploadLengthDeferred, }: { - encodeMetadataValue: (value: string) => string endpoint: string metadata: Record protocol: string @@ -2625,7 +2625,6 @@ export function tusCreateUploadRequestPlan({ return tusRequestPlanForOperation({ headers: { ...tusCreateUploadHeaders({ - encodeMetadataValue, metadata, size, uploadLengthDeferred, @@ -2641,13 +2640,11 @@ export function tusCreateUploadRequestPlan({ } export function tusFinalUploadRequestPlan({ - encodeMetadataValue, endpoint, metadata, protocol, uploadUrls, }: { - encodeMetadataValue: (value: string) => string endpoint: string metadata: Record protocol: string @@ -2655,7 +2652,6 @@ export function tusFinalUploadRequestPlan({ }): TusRequestPlan { return tusRequestPlanForOperation({ headers: tusFinalUploadHeaders({ - encodeMetadataValue, metadata, uploadUrls, }), diff --git a/lib/upload.ts b/lib/upload.ts index ec3b74afa..569067ef3 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,4 +1,3 @@ -import { Base64 } from 'js-base64' // TODO: Package url-parse is CommonJS. Can we replace this with a ESM package that // provides WHATWG URL? Then we can get rid of @rollup/plugin-commonjs. import URL from 'url-parse' @@ -342,7 +341,6 @@ export class BaseUpload { const req = this._openRequest( tusFinalUploadRequestPlan({ endpoint: finalUploadCreationPlan.endpoint, - encodeMetadataValue, metadata: this.options.metadata, protocol: this.options.protocol, uploadUrls: finalUploadCreationPlan.uploadUrls, @@ -615,7 +613,6 @@ export class BaseUpload { const req = this._openRequest( tusCreateUploadRequestPlan({ endpoint: creationRequestPlan.endpoint, - encodeMetadataValue, metadata: this.options.metadata, protocol: this.options.protocol, size: this._size, @@ -989,10 +986,6 @@ export class BaseUpload { } } -function encodeMetadataValue(value: string): string { - return Base64.encode(value) -} - function setRequestHeaders(req: HttpRequest, headers: Record): void { for (const [name, value] of Object.entries(headers)) { req.setHeader(name, value) From 158663eb7adac6e72092bafd9eebb16c8d91c68c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:54:19 +0200 Subject: [PATCH 064/155] Use generated TUS Location resolution --- lib/protocol_generated.ts | 9 ++++++--- lib/upload.ts | 15 --------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 40b622950..21def3188 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -3,6 +3,7 @@ // belongs in the protocol contract generator so all TUS clients stay in sync. import { Base64 } from 'js-base64' +import URL from 'url-parse' export const TUS_DEFAULT_PROTOCOL_VERSION = '1.0.0' @@ -987,21 +988,23 @@ export function tusPlanRequestHeaders({ } } +export function tusResolveRelativeUrl(baseUrl: string, relativeOrAbsoluteUrl: string): string { + return new URL(relativeOrAbsoluteUrl, baseUrl).toString() +} + export function tusResolveUploadLocation({ location, requestUrl, - resolveRelativeUrl, }: { location: string requestUrl: string - resolveRelativeUrl: (baseUrl: string, relativeOrAbsoluteUrl: string) => string }): string { const policy = TUS_FLOW_POLICY.locationResolution if (policy.strategy !== 'relative-to-creation-request-url') { throw new Error(`tus: unsupported Location resolution strategy ${policy.strategy}`) } - return resolveRelativeUrl(requestUrl, location) + return tusResolveRelativeUrl(requestUrl, location) } function tusAssertRequestLifecyclePolicySupported(): void { diff --git a/lib/upload.ts b/lib/upload.ts index 569067ef3..9e941566f 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,6 +1,3 @@ -// TODO: Package url-parse is CommonJS. Can we replace this with a ESM package that -// provides WHATWG URL? Then we can get rid of @rollup/plugin-commonjs. -import URL from 'url-parse' import { DetailedError } from './DetailedError.js' import { log } from './logger.js' import type { @@ -373,7 +370,6 @@ export class BaseUpload { this.url = tusResolveUploadLocation({ location: creationResponsePlan.location, requestUrl: finalUploadCreationPlan.endpoint, - resolveRelativeUrl, }) log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) @@ -657,7 +653,6 @@ export class BaseUpload { this.url = tusResolveUploadLocation({ location: creationResponsePlan.location, requestUrl: creationRequestPlan.endpoint, - resolveRelativeUrl, }) log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) @@ -1098,16 +1093,6 @@ function defaultOnShouldRetry(err: DetailedError): boolean { return tusDefaultRetryPolicyDecision({ isOnline: isOnline(), status }) } -/** - * Resolve a relative link given the origin as source. For example, - * if a HTTP request to http://example.com/files/ returns a Location - * header with the value /upload/abc, the resolved URL will be: - * http://example.com/upload/abc - */ -function resolveRelativeUrl(baseUrl: string, relativeOrAbsoluteUrl: string): string { - return new URL(relativeOrAbsoluteUrl, baseUrl).toString() -} - function wait(delay: number) { return new Promise((resolve) => { setTimeout(resolve, delay) From 4929c29c36a6ef77101b580efae40e0f060cf107 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 20:57:57 +0200 Subject: [PATCH 065/155] Use generated TUS URL storage timestamps --- lib/protocol_generated.ts | 6 ++++++ lib/upload.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 21def3188..7eb8d566c 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -1742,6 +1742,12 @@ export function tusShouldRemoveStoredUploadOnSuccess({ return removeFingerprintOnSuccess } +export function tusUrlStorageCreationTime({ now }: { now: Date }): string { + tusAssertUrlStorageRecordPolicySupported() + + return now.toString() +} + export function tusPlanStoredUploadRecord({ creationTime, fingerprint, diff --git a/lib/upload.ts b/lib/upload.ts index 9e941566f..f92b55cae 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -62,6 +62,7 @@ import { tusTerminateUploadRequestPlan, tusUploadBodyHeaders, tusUploadLengthHeaders, + tusUrlStorageCreationTime, tusValidateUploadStart, } from './protocol_generated.js' import { uuid } from './uuid.js' @@ -951,7 +952,7 @@ export class BaseUpload { } const recordPlan = tusPlanStoredUploadRecord({ - creationTime: new Date().toString(), + creationTime: tusUrlStorageCreationTime({ now: new Date() }), fingerprint: storagePlan.fingerprint, metadata: this.options.metadata, parallelUploadUrls: this._parallelUploadUrls, From 6cef8183a73ccc78890419cfe3f6855614d98def Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 21:08:27 +0200 Subject: [PATCH 066/155] Use generated TUS request ID planning --- lib/protocol_generated.ts | 19 +++++++++++++++---- lib/upload.ts | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 7eb8d566c..1b881b3cb 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -973,10 +973,6 @@ export function tusPlanRequestHeaders({ throw new Error(`tus: unsupported request header layer policy ${policy.layers.join('|')}`) } - if (policy.requestIdSource !== 'sdk-generated-uuid') { - throw new Error(`tus: unsupported request ID source ${policy.requestIdSource}`) - } - if (addRequestId && !requestId) { throw new Error('tus: request ID is required when addRequestId is enabled') } @@ -988,6 +984,21 @@ export function tusPlanRequestHeaders({ } } +export function tusPlanRequestId({ + addRequestId, + generateRequestId, +}: { + addRequestId: boolean + generateRequestId: () => string +}): string | undefined { + const policy = TUS_FLOW_POLICY.requestHeaders + if (policy.requestIdSource !== 'sdk-generated-uuid') { + throw new Error(`tus: unsupported request ID source ${policy.requestIdSource}`) + } + + return addRequestId ? generateRequestId() : undefined +} + export function tusResolveRelativeUrl(baseUrl: string, relativeOrAbsoluteUrl: string): string { return new URL(relativeOrAbsoluteUrl, baseUrl).toString() } diff --git a/lib/upload.ts b/lib/upload.ts index f92b55cae..d414cdeed 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -35,6 +35,7 @@ import { tusPlanPreparedUploadSize, tusPlanRemovedResumeOptionWarning, tusPlanRequestHeaders, + tusPlanRequestId, tusPlanRequestLifecycleHooks, tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, @@ -997,7 +998,10 @@ function setRequestHeaders(req: HttpRequest, headers: Record): v */ function openRequest(plan: TusRequestPlan, options: UploadOptions): HttpRequest { const req = options.httpStack.createRequest(plan.method, plan.url) - const requestId = options.addRequestId ? uuid() : undefined + const requestId = tusPlanRequestId({ + addRequestId: options.addRequestId, + generateRequestId: uuid, + }) setRequestHeaders( req, From 5d46da2c1371b45a55ecb6133c9b5cb7dc98eb14 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 21:14:27 +0200 Subject: [PATCH 067/155] Use generated TUS retryability policy --- lib/protocol_generated.ts | 17 +++++++++++++++++ lib/upload.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 1b881b3cb..c31c9340c 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -386,6 +386,9 @@ export const TUS_FLOW_POLICY = { retry: { customDecision: 'custom-callback-before-default-decision', defaultDecision: 'retryable-status-and-online', + error: { + retryableWhen: 'request-context-present', + }, evaluationTrigger: 'generated-plan-evaluate-policy', onlineSignal: { defaultWhenUnavailable: true, @@ -1041,6 +1044,10 @@ function tusAssertRequestLifecyclePolicySupported(): void { throw new Error(`tus: unsupported default retry decision ${policy.retry.defaultDecision}`) } + if (policy.retry.error.retryableWhen !== 'request-context-present') { + throw new Error(`tus: unsupported retryable error policy ${policy.retry.error.retryableWhen}`) + } + if (policy.retry.onlineSignal.source !== 'sdk-platform-online-status') { throw new Error(`tus: unsupported retry online signal ${policy.retry.onlineSignal.source}`) } @@ -1092,6 +1099,16 @@ export function tusShouldEvaluateRetryPolicy({ return retryPlanAction === 'evaluatePolicy' && hasRetryableError } +export function tusShouldTreatRequestErrorAsRetryable({ + hasRequestContext, +}: { + hasRequestContext: boolean +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + return hasRequestContext +} + export function tusShouldUseCustomRetryPolicy({ hasCustomRetryPolicy, }: { diff --git a/lib/upload.ts b/lib/upload.ts index d414cdeed..049091315 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -59,6 +59,7 @@ import { tusShouldEvaluateRetryPolicy, tusShouldRemoveStoredUploadOnSuccess, tusShouldSendUploadBodyDuringCreation, + tusShouldTreatRequestErrorAsRetryable, tusShouldUseCustomRetryPolicy, tusTerminateUploadRequestPlan, tusUploadBodyHeaders, @@ -1064,7 +1065,10 @@ function isOnline(): boolean { * Extract errors that originated from a request. Only these can safely be retried. */ function getRetryableError(err: Error): DetailedError | null { - if (err instanceof DetailedError && err.originalRequest != null) { + if ( + err instanceof DetailedError && + tusShouldTreatRequestErrorAsRetryable({ hasRequestContext: err.originalRequest != null }) + ) { return err } From 5325d89c7b2c2173c60c58260535e8ce2693d3c3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 21:25:19 +0200 Subject: [PATCH 068/155] Use generated TUS abort error suppression --- lib/protocol_generated.ts | 7 +++++++ lib/upload.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index c31c9340c..7ecc39bf0 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -195,6 +195,7 @@ export const TUS_FLOW_POLICY = { 'clear-retry-timer', 'terminate-upload-if-requested', ], + suppressErrorAfterAbort: true, terminateUpload: 'when-requested-and-upload-url-known', }, detailedErrors: { @@ -1162,6 +1163,12 @@ function tusAssertAbortPolicySupported(): void { } } +export function tusShouldSuppressErrorAfterAbort({ aborted }: { aborted: boolean }): boolean { + tusAssertAbortPolicySupported() + + return TUS_FLOW_POLICY.abort.suppressErrorAfterAbort && aborted +} + export function tusAbortErrorDescriptor(): TusAbortErrorDescriptor { tusAssertAbortPolicySupported() diff --git a/lib/upload.ts b/lib/upload.ts index 049091315..de2c16cfa 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -59,6 +59,7 @@ import { tusShouldEvaluateRetryPolicy, tusShouldRemoveStoredUploadOnSuccess, tusShouldSendUploadBodyDuringCreation, + tusShouldSuppressErrorAfterAbort, tusShouldTreatRequestErrorAsRetryable, tusShouldUseCustomRetryPolicy, tusTerminateUploadRequestPlan, @@ -469,8 +470,7 @@ export class BaseUpload { } private _emitError(err: Error): void { - // Do not emit errors, e.g. from aborted HTTP requests, if the upload has been stopped. - if (this._aborted) return + if (tusShouldSuppressErrorAfterAbort({ aborted: this._aborted })) return if (typeof this.options.onError === 'function') { this.options.onError(err) From 950e3b50e727daa01a3ce987f643f513ff40d5cf Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 21:55:42 +0200 Subject: [PATCH 069/155] Use generated TUS upload event planning --- lib/protocol_generated.ts | 207 ++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 91 ++++++++++++----- 2 files changed, 273 insertions(+), 25 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 7ecc39bf0..657cd2ac9 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -206,6 +206,21 @@ export const TUS_FLOW_POLICY = { ', originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})', }, eventHooks: { + chunkComplete: { + afterChunkAccepted: 'accepted-chunk-size-and-offset', + }, + progress: { + afterChunkAccepted: 'accepted-offset', + afterResumeAlreadyComplete: 'upload-length', + beforeRequestBody: 'current-offset', + duringRequest: 'start-offset-plus-transmitted-bytes', + parallelPartProgress: 'aggregated-part-progress', + }, + success: { + closeSource: 'after-hook-when-source-open', + emit: 'after-upload-complete', + removeStoredUrl: 'before-hook-when-option-enabled', + }, uploadUrlAvailable: { createUpload: 'after-url-known-before-storage', parallelFinalUpload: 'not-emitted', @@ -718,6 +733,65 @@ export interface TusUploadUrlAvailableHookPlan { shouldCall: boolean } +export type TusProgressEventPlanInput = + | { + bytesTotal: number | null + hasHook: boolean + phase: 'afterChunkAccepted' + uploadOffset: number + } + | { + bytesTotal: number | null + currentOffset: number + hasHook: boolean + phase: 'beforeRequestBody' + } + | { + bytesTotal: number | null + hasHook: boolean + phase: 'duringRequest' + startOffset: number + transmittedBytes: number + } + | { + bytesTotal: number | null + hasHook: boolean + phase: 'parallelPartProgress' + totalProgress: number + } + | { + hasHook: boolean + phase: 'afterResumeAlreadyComplete' + uploadLength: number + } + +export interface TusProgressEventPlan { + bytesSent: number + bytesTotal: number | null + shouldCall: boolean +} + +export type TusChunkCompleteEventPlanInput = { + bytesAccepted: number + bytesTotal: number | null + chunkSize: number + hasHook: boolean + phase: 'afterChunkAccepted' +} + +export interface TusChunkCompleteEventPlan { + bytesAccepted: number + bytesTotal: number | null + chunkSize: number + shouldCall: boolean +} + +export interface TusSuccessEventPlan { + closeSource: boolean + removeStoredUpload: boolean + shouldCall: boolean +} + export type TusFileSourceChunkSizeValidationResult = | { chunkSize: number; ok: true } | { message: string; ok: false; reason: 'missingFiniteChunkSize' } @@ -1267,6 +1341,139 @@ export function tusPlanUploadUrlAvailableHook({ } } +function tusAssertEventHookPolicySupported(): void { + tusAssertUploadUrlAvailableHookPolicySupported() + + const policy = TUS_FLOW_POLICY.eventHooks + if (policy.progress.afterChunkAccepted !== 'accepted-offset') { + throw new Error( + `tus: unsupported chunk-accepted progress hook policy ${policy.progress.afterChunkAccepted}`, + ) + } + + if (policy.progress.afterResumeAlreadyComplete !== 'upload-length') { + throw new Error( + `tus: unsupported completed-resume progress hook policy ${policy.progress.afterResumeAlreadyComplete}`, + ) + } + + if (policy.progress.beforeRequestBody !== 'current-offset') { + throw new Error( + `tus: unsupported request-body progress hook policy ${policy.progress.beforeRequestBody}`, + ) + } + + if (policy.progress.duringRequest !== 'start-offset-plus-transmitted-bytes') { + throw new Error( + `tus: unsupported request progress hook policy ${policy.progress.duringRequest}`, + ) + } + + if (policy.progress.parallelPartProgress !== 'aggregated-part-progress') { + throw new Error( + `tus: unsupported parallel progress hook policy ${policy.progress.parallelPartProgress}`, + ) + } + + if (policy.chunkComplete.afterChunkAccepted !== 'accepted-chunk-size-and-offset') { + throw new Error( + `tus: unsupported chunk-complete hook policy ${policy.chunkComplete.afterChunkAccepted}`, + ) + } + + if (policy.success.closeSource !== 'after-hook-when-source-open') { + throw new Error(`tus: unsupported success source-close policy ${policy.success.closeSource}`) + } + + if (policy.success.emit !== 'after-upload-complete') { + throw new Error(`tus: unsupported success hook policy ${policy.success.emit}`) + } + + if (policy.success.removeStoredUrl !== 'before-hook-when-option-enabled') { + throw new Error( + `tus: unsupported success storage cleanup policy ${policy.success.removeStoredUrl}`, + ) + } +} + +export function tusPlanProgressEvent(input: TusProgressEventPlanInput): TusProgressEventPlan { + tusAssertEventHookPolicySupported() + + if (input.phase === 'afterChunkAccepted') { + return { + bytesSent: input.uploadOffset, + bytesTotal: input.bytesTotal, + shouldCall: input.hasHook, + } + } + + if (input.phase === 'afterResumeAlreadyComplete') { + return { + bytesSent: input.uploadLength, + bytesTotal: input.uploadLength, + shouldCall: input.hasHook, + } + } + + if (input.phase === 'beforeRequestBody') { + return { + bytesSent: input.currentOffset, + bytesTotal: input.bytesTotal, + shouldCall: input.hasHook, + } + } + + if (input.phase === 'duringRequest') { + return { + bytesSent: input.startOffset + input.transmittedBytes, + bytesTotal: input.bytesTotal, + shouldCall: input.hasHook, + } + } + + if (input.phase === 'parallelPartProgress') { + return { + bytesSent: input.totalProgress, + bytesTotal: input.bytesTotal, + shouldCall: input.hasHook, + } + } + + const exhaustive: never = input + throw new Error(`tus: unsupported progress event phase ${JSON.stringify(exhaustive)}`) +} + +export function tusPlanChunkCompleteEvent( + input: TusChunkCompleteEventPlanInput, +): TusChunkCompleteEventPlan { + tusAssertEventHookPolicySupported() + + return { + bytesAccepted: input.bytesAccepted, + bytesTotal: input.bytesTotal, + chunkSize: input.chunkSize, + shouldCall: input.hasHook, + } +} + +export function tusPlanSuccessEvent({ + hasHook, + hasSource, + removeFingerprintOnSuccess, +}: { + hasHook: boolean + hasSource: boolean + removeFingerprintOnSuccess: boolean +}): TusSuccessEventPlan { + tusAssertEventHookPolicySupported() + + return { + closeSource: hasSource, + removeStoredUpload: tusShouldRemoveStoredUploadOnSuccess({ removeFingerprintOnSuccess }), + shouldCall: hasHook, + } +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } diff --git a/lib/upload.ts b/lib/upload.ts index de2c16cfa..72ecd1b02 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -10,6 +10,8 @@ import type { UploadOptions, } from './options.js' import { + type TusChunkCompleteEventPlanInput, + type TusProgressEventPlanInput, type TusRequestPlan, type TusUploadUrlAvailableHookContext, tusCheckConfiguredUploadSize, @@ -24,6 +26,7 @@ import { tusNonErrorThrownValueMessage, tusPatchUploadRequestPlan, tusPlanAbort, + tusPlanChunkCompleteEvent, tusPlanCreatedUploadLog, tusPlanFinalUploadCreation, tusPlanFingerprint, @@ -33,6 +36,7 @@ import { tusPlanPreparedFingerprintLog, tusPlanPreparedUploadMode, tusPlanPreparedUploadSize, + tusPlanProgressEvent, tusPlanRemovedResumeOptionWarning, tusPlanRequestHeaders, tusPlanRequestId, @@ -43,6 +47,7 @@ import { tusPlanRetryAfterError, tusPlanSingleUploadStart, tusPlanStoredUploadRecord, + tusPlanSuccessEvent, tusPlanTerminateResponse, tusPlanTerminateUploadRequest, tusPlanUploadChunkRequest, @@ -57,7 +62,6 @@ import { tusReadUploadOffsetResponse, tusResolveUploadLocation, tusShouldEvaluateRetryPolicy, - tusShouldRemoveStoredUploadOnSuccess, tusShouldSendUploadBodyDuringCreation, tusShouldSuppressErrorAfterAbort, tusShouldTreatRequestErrorAsRetryable, @@ -297,7 +301,12 @@ export class BaseUpload { onProgress: (newPartProgress: number) => { totalProgress = totalProgress - lastPartProgress + newPartProgress lastPartProgress = newPartProgress - this._emitProgress(totalProgress, totalSize) + this._emitProgress({ + bytesTotal: totalSize, + hasHook: typeof this.options.onProgress === 'function', + phase: 'parallelPartProgress', + totalProgress, + }) }, // Wait until every partial upload has an upload URL, so we can add // them to the URL storage. @@ -532,19 +541,25 @@ export class BaseUpload { * @api private */ private async _emitSuccess(lastResponse: HttpResponse): Promise { - if ( - tusShouldRemoveStoredUploadOnSuccess({ - removeFingerprintOnSuccess: this.options.removeFingerprintOnSuccess, - }) - ) { + const eventPlan = tusPlanSuccessEvent({ + hasHook: typeof this.options.onSuccess === 'function', + hasSource: this._source != null, + removeFingerprintOnSuccess: this.options.removeFingerprintOnSuccess, + }) + + if (eventPlan.removeStoredUpload) { // Remove stored fingerprint and corresponding endpoint. This causes // new uploads of the same file to be treated as a different file. await this._removeFromUrlStorage() } - if (typeof this.options.onSuccess === 'function') { + if (eventPlan.shouldCall && typeof this.options.onSuccess === 'function') { this.options.onSuccess({ lastResponse }) } + + if (eventPlan.closeSource) { + this._source?.close() + } } /** @@ -555,9 +570,10 @@ export class BaseUpload { * @param {number|null} bytesTotal Total number of bytes to be sent to the server. * @api private */ - private _emitProgress(bytesSent: number, bytesTotal: number | null): void { - if (typeof this.options.onProgress === 'function') { - this.options.onProgress(bytesSent, bytesTotal) + private _emitProgress(input: TusProgressEventPlanInput): void { + const eventPlan = tusPlanProgressEvent(input) + if (eventPlan.shouldCall && typeof this.options.onProgress === 'function') { + this.options.onProgress(eventPlan.bytesSent, eventPlan.bytesTotal) } } @@ -570,13 +586,14 @@ export class BaseUpload { * @param {number|null} bytesTotal Total number of bytes to be sent to the server. * @api private */ - private _emitChunkComplete( - chunkSize: number, - bytesAccepted: number, - bytesTotal: number | null, - ): void { - if (typeof this.options.onChunkComplete === 'function') { - this.options.onChunkComplete(chunkSize, bytesAccepted, bytesTotal) + private _emitChunkComplete(input: TusChunkCompleteEventPlanInput): void { + const eventPlan = tusPlanChunkCompleteEvent(input) + if (eventPlan.shouldCall && typeof this.options.onChunkComplete === 'function') { + this.options.onChunkComplete( + eventPlan.chunkSize, + eventPlan.bytesAccepted, + eventPlan.bytesTotal, + ) } } @@ -664,7 +681,6 @@ export class BaseUpload { if (creationResponsePlan.action === 'complete') { // Nothing to upload and file was successfully created await this._emitSuccess(res) - if (this._source) this._source.close() return } @@ -753,7 +769,11 @@ export class BaseUpload { // data to the server const completionPlan = tusPlanUploadCompletionAfterOffset({ length, offset }) if (completionPlan.complete) { - this._emitProgress(completionPlan.length, completionPlan.length) + this._emitProgress({ + hasHook: typeof this.options.onProgress === 'function', + phase: 'afterResumeAlreadyComplete', + uploadLength: completionPlan.length, + }) await this._emitSuccess(res) return } @@ -830,7 +850,13 @@ export class BaseUpload { }) req.setProgressHandler((bytesSent) => { - this._emitProgress(start + bytesSent, this._size) + this._emitProgress({ + bytesTotal: this._size, + hasHook: typeof this.options.onProgress === 'function', + phase: 'duringRequest', + startOffset: start, + transmittedBytes: bytesSent, + }) }) // TODO: What happens if abort is called during slice? @@ -872,7 +898,12 @@ export class BaseUpload { return await this._sendRequest(req) } - this._emitProgress(this._offset, this._size) + this._emitProgress({ + bytesTotal: this._size, + currentOffset: this._offset, + hasHook: typeof this.options.onProgress === 'function', + phase: 'beforeRequestBody', + }) return await this._sendRequest(req, value) } @@ -895,15 +926,25 @@ export class BaseUpload { throw new DetailedError(chunkResponsePlan.message, undefined, req, res) } - this._emitProgress(chunkResponsePlan.offset, this._size) - this._emitChunkComplete(chunkResponsePlan.chunkSize, chunkResponsePlan.offset, this._size) + this._emitProgress({ + bytesTotal: this._size, + hasHook: typeof this.options.onProgress === 'function', + phase: 'afterChunkAccepted', + uploadOffset: chunkResponsePlan.offset, + }) + this._emitChunkComplete({ + bytesAccepted: chunkResponsePlan.offset, + bytesTotal: this._size, + chunkSize: chunkResponsePlan.chunkSize, + hasHook: typeof this.options.onChunkComplete === 'function', + phase: 'afterChunkAccepted', + }) this._offset = chunkResponsePlan.offset if (chunkResponsePlan.action === 'complete') { // Yay, finally done :) await this._emitSuccess(res) - if (this._source) this._source.close() return } From 01f36f6eb2a88866252723fb670468cff5834602 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 22:30:27 +0200 Subject: [PATCH 070/155] Assert generated TUS upload events --- test/spec/generated-protocol-contract.js | 85 ++++++++++++- test/spec/test-generated-protocol-contract.js | 120 ++++++++++++++++-- 2 files changed, 193 insertions(+), 12 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b56af9dcf..1dda1b2f4 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -606,8 +606,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['singleUploadLifecycle', 'creationWithUpload', 'resumeFromPreviousUpload'], + status: 'covered-by-generated-scenario', }, description: 'Expose progress and accepted-chunk callbacks from runtime upload activity.', featureId: 'uploadCallbacks', @@ -811,6 +811,33 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/generated-contract', }, + events: [ + { + kind: 'upload-url-available', + }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], featureId: 'singleUploadLifecycle', input: { content: 'hello world', @@ -866,6 +893,27 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', }, + events: [ + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + }, + { + kind: 'upload-url-available', + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], featureId: 'creationWithUpload', input: { content: 'hello world', @@ -904,6 +952,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', }, + events: [], featureId: 'uploadBodyHeaders', input: { content: 'hello world', @@ -953,6 +1002,33 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/resume-contract', }, + events: [ + { + kind: 'upload-url-available', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 6, + kind: 'chunk-complete', + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], featureId: 'resumeUpload', input: { content: 'hello world', @@ -1000,6 +1076,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/deferred-contract', }, + events: [], featureId: 'deferredLengthUpload', input: { chunkSize: 100, @@ -1052,6 +1129,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/override-contract', }, + events: [], featureId: 'overridePatchMethod', input: { content: 'hello world', @@ -1102,6 +1180,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/parallel-final', }, + events: [], featureId: 'parallelUploadConcat', input: { content: 'hello world', @@ -1209,6 +1288,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/retry-contract', }, + events: [], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -1280,6 +1360,7 @@ export const tusClientConformanceScenarios = [ kind: 'terminated', uploadUrl: 'https://tus.io/uploads/terminate-contract', }, + events: [], featureId: 'terminateUpload', input: { chunkSize: 5, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 9908c8bf2..37b79b55c 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -1,4 +1,4 @@ -import { Upload } from 'tus-js-client' +import { defaultOptions, Upload } from 'tus-js-client' import { tusClientConformanceScenarios, tusClientFeatures, @@ -164,6 +164,72 @@ function makeStoredUploadStorage(storedUpload) { } } +function scenarioWantsEvent(scenario, kind) { + return scenario.events.some((event) => event.kind === kind) +} + +function makeEventRecordingFileReader(fileReader, observedEvents) { + return { + async openFile(input, chunkSize) { + const source = await fileReader.openFile(input, chunkSize) + + return { + get size() { + return source.size + }, + close() { + observedEvents.push({ kind: 'source-close' }) + source.close() + }, + slice(start, end) { + return source.slice(start, end) + }, + } + }, + } +} + +function eventMatchesExpectation(actual, expected) { + if (actual.kind !== expected.kind) { + return false + } + + if (expected.kind === 'progress') { + return actual.bytesSent === expected.bytesSent && actual.bytesTotal === expected.bytesTotal + } + + if (expected.kind === 'chunk-complete') { + return ( + actual.bytesAccepted === expected.bytesAccepted && + actual.bytesTotal === expected.bytesTotal && + actual.chunkSize === expected.chunkSize + ) + } + + return true +} + +function expectScenarioEvents(scenario, observedEvents) { + let searchStart = 0 + + for (const expectedEvent of scenario.events) { + const matchedIndex = observedEvents.findIndex( + (actualEvent, index) => + index >= searchStart && eventMatchesExpectation(actualEvent, expectedEvent), + ) + + expect(matchedIndex) + .withContext( + `Expected generated scenario ${scenario.scenarioId} to emit ${JSON.stringify( + expectedEvent, + )} after event index ${searchStart - 1}; observed ${JSON.stringify(observedEvents)}`, + ) + .not.toBe(-1) + + searchStart = matchedIndex + 1 + } +} + function expectedUrlForScenarioRequest(scenario, request) { if (request.url === 'endpoint') { return scenario.input.endpointUrl @@ -211,12 +277,36 @@ function expectScenarioRequest(req, scenario, request) { async function startScenarioUpload(scenario, testStack) { let upload let terminatePromise + const observedEvents = [] + const onError = waitableFunction('onError') + const onSuccess = waitableFunction('onSuccess') const options = { endpoint: scenario.input.endpointUrl, httpStack: testStack, metadata: scenario.input.metadata ?? {}, - onError: waitableFunction('onError'), - onSuccess: waitableFunction('onSuccess'), + onError, + onSuccess(payload) { + if (scenarioWantsEvent(scenario, 'success')) { + observedEvents.push({ kind: 'success' }) + } + onSuccess(payload) + }, + } + + if (scenarioWantsEvent(scenario, 'progress')) { + options.onProgress = (bytesSent, bytesTotal) => { + observedEvents.push({ bytesSent, bytesTotal, kind: 'progress' }) + } + } + + if (scenarioWantsEvent(scenario, 'upload-url-available')) { + options.onUploadUrlAvailable = () => { + observedEvents.push({ kind: 'upload-url-available' }) + } + } + + if (scenarioWantsEvent(scenario, 'source-close')) { + options.fileReader = makeEventRecordingFileReader(defaultOptions.fileReader, observedEvents) } if (scenario.input.chunkSize != null) { @@ -261,9 +351,16 @@ async function startScenarioUpload(scenario, testStack) { } if (scenario.behavior === 'terminate-with-retry') { - options.onChunkComplete = () => { + options.onChunkComplete = (chunkSize, bytesAccepted, bytesTotal) => { + if (scenarioWantsEvent(scenario, 'chunk-complete')) { + observedEvents.push({ bytesAccepted, bytesTotal, chunkSize, kind: 'chunk-complete' }) + } terminatePromise = upload.abort(true) } + } else if (scenarioWantsEvent(scenario, 'chunk-complete')) { + options.onChunkComplete = (chunkSize, bytesAccepted, bytesTotal) => { + observedEvents.push({ bytesAccepted, bytesTotal, chunkSize, kind: 'chunk-complete' }) + } } upload = new Upload(createScenarioInput(scenario.input), options) @@ -276,7 +373,7 @@ async function startScenarioUpload(scenario, testStack) { upload.start() - return { options, terminatePromise: () => terminatePromise, upload } + return { observedEvents, onError, onSuccess, terminatePromise: () => terminatePromise, upload } } async function runGeneratedConformanceScenario(scenario) { @@ -284,7 +381,8 @@ async function runGeneratedConformanceScenario(scenario) { expect(feature.primitives).toEqual(jasmine.arrayContaining(scenario.primitives)) const testStack = new TestHttpStack() - const { options, terminatePromise, upload } = await startScenarioUpload(scenario, testStack) + const { observedEvents, onError, onSuccess, terminatePromise, upload } = + await startScenarioUpload(scenario, testStack) for (const request of scenario.requests) { const req = await testStack.nextRequest() @@ -294,14 +392,16 @@ async function runGeneratedConformanceScenario(scenario) { if (scenario.completion.kind === 'terminated') { await terminatePromise() expect(upload.url).toBe(scenario.completion.uploadUrl) - expect(options.onSuccess).not.toHaveBeenCalled() - expect(options.onError).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) return } - await options.onSuccess.toBeCalled() + await onSuccess.toBeCalled() expect(upload.url).toBe(scenario.completion.uploadUrl) - expect(options.onError).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) } describe('generated TUS protocol contract', () => { From de01c0a39cdfd9b322f53a03d66448bac7956502 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 23:19:52 +0200 Subject: [PATCH 071/155] Cover TUS request lifecycle conformance --- test/spec/generated-protocol-contract.js | 58 ++++++++++++- test/spec/test-generated-protocol-contract.js | 87 ++++++++++++++++++- 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 1dda1b2f4..d960a4192 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -633,8 +633,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['requestLifecycleHooks', 'retryPatchAfterOffsetRecovery'], + status: 'covered-by-generated-scenario', }, description: 'Run before-request, after-response, and custom retry hooks around transport.', featureId: 'requestLifecycleHooks', @@ -1288,7 +1288,13 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/retry-contract', }, - events: [], + events: [ + { + decision: true, + kind: 'should-retry', + retryAttempt: 0, + }, + ], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -1354,6 +1360,52 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'retryPatchAfterOffsetRecovery', }, + { + behavior: 'request-lifecycle-hooks', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/request-hooks-contract', + }, + events: [ + { + kind: 'before-request', + requestIndex: 0, + }, + { + kind: 'after-response', + requestIndex: 0, + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'requestLifecycleHooks', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + uploadUrl: 'https://tus.io/uploads/request-hooks-contract', + }, + operationIds: ['getTusUploadOffset'], + primitives: ['run-request-hooks'], + requests: [ + { + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '11', + }, + statusCode: 200, + }, + url: 'upload', + }, + ], + scenarioId: 'requestLifecycleHooks', + }, { behavior: 'terminate-with-retry', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 37b79b55c..29bce11f4 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -189,7 +189,18 @@ function makeEventRecordingFileReader(fileReader, observedEvents) { } } -function eventMatchesExpectation(actual, expected) { +function requestExpectationForEvent(scenario, event) { + const request = scenario.requests[event.requestIndex] + if (!request) { + throw new Error( + `Generated scenario ${scenario.scenarioId} event points at missing request index ${event.requestIndex}`, + ) + } + + return request +} + +function eventMatchesExpectation(scenario, actual, expected) { if (actual.kind !== expected.kind) { return false } @@ -206,6 +217,28 @@ function eventMatchesExpectation(actual, expected) { ) } + if (expected.kind === 'before-request' || expected.kind === 'after-response') { + const request = requestExpectationForEvent(scenario, expected) + const operation = getProtocolOperation(request.operationId) + if ( + actual.requestIndex !== expected.requestIndex || + actual.method !== (request.method ?? operation.method) || + actual.url !== expectedUrlForScenarioRequest(scenario, request) + ) { + return false + } + + if (expected.kind === 'after-response') { + return actual.statusCode === request.response.statusCode + } + + return true + } + + if (expected.kind === 'should-retry') { + return actual.decision === expected.decision && actual.retryAttempt === expected.retryAttempt + } + return true } @@ -215,7 +248,7 @@ function expectScenarioEvents(scenario, observedEvents) { for (const expectedEvent of scenario.events) { const matchedIndex = observedEvents.findIndex( (actualEvent, index) => - index >= searchStart && eventMatchesExpectation(actualEvent, expectedEvent), + index >= searchStart && eventMatchesExpectation(scenario, actualEvent, expectedEvent), ) expect(matchedIndex) @@ -277,6 +310,9 @@ function expectScenarioRequest(req, scenario, request) { async function startScenarioUpload(scenario, testStack) { let upload let terminatePromise + let afterResponseRequestIndex = 0 + let beforeRequestIndex = 0 + let retryDecisionIndex = 0 const observedEvents = [] const onError = waitableFunction('onError') const onSuccess = waitableFunction('onSuccess') @@ -293,6 +329,31 @@ async function startScenarioUpload(scenario, testStack) { }, } + if (scenarioWantsEvent(scenario, 'before-request')) { + options.onBeforeRequest = (req) => { + observedEvents.push({ + kind: 'before-request', + method: req.getMethod(), + requestIndex: beforeRequestIndex, + url: req.getURL(), + }) + beforeRequestIndex += 1 + } + } + + if (scenarioWantsEvent(scenario, 'after-response')) { + options.onAfterResponse = (req, res) => { + observedEvents.push({ + kind: 'after-response', + method: req.getMethod(), + requestIndex: afterResponseRequestIndex, + statusCode: res.getStatus(), + url: req.getURL(), + }) + afterResponseRequestIndex += 1 + } + } + if (scenarioWantsEvent(scenario, 'progress')) { options.onProgress = (bytesSent, bytesTotal) => { observedEvents.push({ bytesSent, bytesTotal, kind: 'progress' }) @@ -309,6 +370,27 @@ async function startScenarioUpload(scenario, testStack) { options.fileReader = makeEventRecordingFileReader(defaultOptions.fileReader, observedEvents) } + if (scenarioWantsEvent(scenario, 'should-retry')) { + options.onShouldRetry = (_error, retryAttempt) => { + const event = scenario.events.filter((candidate) => candidate.kind === 'should-retry')[ + retryDecisionIndex + ] + if (!event) { + throw new Error( + `Generated scenario ${scenario.scenarioId} received unexpected retry decision request ${retryDecisionIndex}`, + ) + } + + observedEvents.push({ + decision: event.decision, + kind: 'should-retry', + retryAttempt, + }) + retryDecisionIndex += 1 + return event.decision + } + } + if (scenario.input.chunkSize != null) { options.chunkSize = scenario.input.chunkSize } @@ -421,6 +503,7 @@ describe('generated TUS protocol contract', () => { 'overridePatchMethod', 'parallelUploadConcat', 'retryPatchAfterOffsetRecovery', + 'requestLifecycleHooks', 'terminateWithRetry', ]) }) From 82769452cde79ee7580a8f8862b1044284bb95e9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:06:10 +0200 Subject: [PATCH 072/155] Cover TUS abort conformance --- test/spec/generated-protocol-contract.js | 38 +++++++++++- test/spec/test-generated-protocol-contract.js | 60 ++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index d960a4192..3536265cc 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -589,8 +589,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['abortUpload'], + status: 'covered-by-generated-scenario', }, description: 'Abort the active request, pending retry timer, and any partial uploads.', featureId: 'abortUpload', @@ -1406,6 +1406,40 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'requestLifecycleHooks', }, + { + behavior: 'abort-upload', + completion: { + kind: 'aborted', + }, + events: [ + { + kind: 'request-abort', + requestIndex: 0, + }, + ], + featureId: 'abortUpload', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload'], + primitives: ['abort-current-request'], + requests: [ + { + abort: true, + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + url: 'endpoint', + }, + ], + scenarioId: 'abortUpload', + }, { behavior: 'terminate-with-retry', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 29bce11f4..61df14582 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -5,7 +5,7 @@ import { tusProtocolOperations, tusWireVersions, } from './generated-protocol-contract.js' -import { getBlob, TestHttpStack, waitableFunction } from './helpers/utils.js' +import { getBlob, TestHttpStack, wait, waitableFunction } from './helpers/utils.js' function getProtocolOperation(operationId) { const operation = tusProtocolOperations.find((candidate) => candidate.operationId === operationId) @@ -229,12 +229,28 @@ function eventMatchesExpectation(scenario, actual, expected) { } if (expected.kind === 'after-response') { + if (!request.response) { + throw new Error( + `Generated scenario ${scenario.scenarioId} after-response event points at request ${expected.requestIndex} without a response`, + ) + } + return actual.statusCode === request.response.statusCode } return true } + if (expected.kind === 'request-abort') { + const request = requestExpectationForEvent(scenario, expected) + const operation = getProtocolOperation(request.operationId) + return ( + actual.requestIndex === expected.requestIndex && + actual.method === (request.method ?? operation.method) && + actual.url === expectedUrlForScenarioRequest(scenario, request) + ) + } + if (expected.kind === 'should-retry') { return actual.decision === expected.decision && actual.retryAttempt === expected.retryAttempt } @@ -301,12 +317,36 @@ function expectScenarioRequest(req, scenario, request) { expect(req.bodySize).toBe(request.bodySize) } + if (!request.response) { + return + } + req.respondWith({ status: request.response.statusCode, responseHeaders: scenarioResponseHeadersFor(operation, request.response), }) } +async function abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload) { + const operation = getProtocolOperation(request.operationId) + const originalAbort = req.abort.bind(req) + req.abort = () => { + observedEvents.push({ + kind: 'request-abort', + method: req.method, + requestIndex, + url: req.url, + }) + return originalAbort() + } + + await upload.abort(false) + await wait(0) + + expect(req.method).toBe(request.method ?? operation.method) + expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) +} + async function startScenarioUpload(scenario, testStack) { let upload let terminatePromise @@ -466,9 +506,24 @@ async function runGeneratedConformanceScenario(scenario) { const { observedEvents, onError, onSuccess, terminatePromise, upload } = await startScenarioUpload(scenario, testStack) - for (const request of scenario.requests) { + for (const [requestIndex, request] of scenario.requests.entries()) { const req = await testStack.nextRequest() expectScenarioRequest(req, scenario, request) + + if (request.abort) { + await abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload) + } else if (!request.response) { + throw new Error( + `Generated scenario ${scenario.scenarioId} request ${requestIndex} has no response and is not marked abort`, + ) + } + } + + if (scenario.completion.kind === 'aborted') { + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) + return } if (scenario.completion.kind === 'terminated') { @@ -504,6 +559,7 @@ describe('generated TUS protocol contract', () => { 'parallelUploadConcat', 'retryPatchAfterOffsetRecovery', 'requestLifecycleHooks', + 'abortUpload', 'terminateWithRetry', ]) }) From 6651c6119520d8086bd93cda715f98da8d74e8d3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:14:09 +0200 Subject: [PATCH 073/155] Cover TUS URL storage conformance --- test/spec/generated-protocol-contract.js | 33 ++++++- test/spec/test-generated-protocol-contract.js | 93 ++++++++++++++----- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 3536265cc..c347c10ac 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -655,8 +655,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['singleUploadLifecycle', 'resumeFromPreviousUpload'], + status: 'covered-by-generated-scenario', }, description: 'Persist, find, resume, and optionally remove upload URLs by fingerprint.', featureId: 'resumeUrlStorage', @@ -812,9 +812,18 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/generated-contract', }, events: [ + { + fingerprint: 'contract-single-fingerprint', + kind: 'fingerprint', + }, { kind: 'upload-url-available', }, + { + fingerprint: 'contract-single-fingerprint', + kind: 'url-storage-add', + uploadUrl: 'https://tus.io/uploads/generated-contract', + }, { bytesSent: 0, bytesTotal: 11, @@ -842,6 +851,7 @@ export const tusClientConformanceScenarios = [ input: { content: 'hello world', endpointUrl: 'https://tus.io/uploads', + fingerprint: 'contract-single-fingerprint', kind: 'blob', metadata: { filename: 'hello.txt', @@ -1003,6 +1013,19 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/resume-contract', }, events: [ + { + fingerprint: 'contract-resume-fingerprint', + kind: 'fingerprint', + }, + { + count: 1, + fingerprint: 'contract-resume-fingerprint', + kind: 'url-storage-find', + }, + { + fingerprint: 'contract-resume-fingerprint', + kind: 'fingerprint', + }, { kind: 'upload-url-available', }, @@ -1022,6 +1045,10 @@ export const tusClientConformanceScenarios = [ chunkSize: 6, kind: 'chunk-complete', }, + { + kind: 'url-storage-remove', + urlStorageKey: 'tus::contract-resume-fingerprint::1337', + }, { kind: 'success', }, @@ -1034,9 +1061,11 @@ export const tusClientConformanceScenarios = [ content: 'hello world', endpointUrl: 'https://tus.io/uploads', kind: 'blob', + removeFingerprintOnSuccess: true, storedUpload: { fingerprint: 'contract-resume-fingerprint', uploadUrl: 'https://tus.io/uploads/resume-contract', + urlStorageKey: 'tus::contract-resume-fingerprint::1337', }, }, operationIds: ['getTusUploadOffset', 'patchTusUpload'], diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 61df14582..a99ac7d2a 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -137,28 +137,39 @@ function createScenarioInput(input) { throw new Error(`Unsupported generated TUS scenario input kind: ${input.kind}`) } -function makeStoredUploadStorage(storedUpload) { +function storedUploadKey(storedUpload) { + return storedUpload.urlStorageKey ?? storedUpload.fingerprint +} + +function storedUploadRecord(storedUpload) { + return { + creationTime: new Date(0).toString(), + metadata: {}, + size: null, + uploadUrl: storedUpload.uploadUrl, + urlStorageKey: storedUploadKey(storedUpload), + } +} + +function makeEventRecordingUrlStorage(storedUpload, observedEvents) { return { findAllUploads() { - return Promise.resolve([ - { - creationTime: new Date(0).toString(), - metadata: {}, - size: null, - uploadUrl: storedUpload.uploadUrl, - urlStorageKey: storedUpload.fingerprint, - }, - ]) + return Promise.resolve(storedUpload ? [storedUploadRecord(storedUpload)] : []) }, findUploadsByFingerprint(fingerprint) { - expect(fingerprint).toBe(storedUpload.fingerprint) - return this.findAllUploads() + const uploads = + storedUpload && storedUpload.fingerprint === fingerprint + ? [storedUploadRecord(storedUpload)] + : [] + observedEvents.push({ count: uploads.length, fingerprint, kind: 'url-storage-find' }) + return Promise.resolve(uploads) }, - addUpload() { - return Promise.resolve(storedUpload.fingerprint) + addUpload(fingerprint, upload) { + observedEvents.push({ fingerprint, kind: 'url-storage-add', uploadUrl: upload.uploadUrl }) + return Promise.resolve(upload.urlStorageKey ?? `${fingerprint}-generated-key`) }, removeUpload(urlStorageKey) { - expect(urlStorageKey).toBe(storedUpload.fingerprint) + observedEvents.push({ kind: 'url-storage-remove', urlStorageKey }) return Promise.resolve() }, } @@ -251,10 +262,26 @@ function eventMatchesExpectation(scenario, actual, expected) { ) } + if (expected.kind === 'fingerprint') { + return actual.fingerprint === expected.fingerprint + } + if (expected.kind === 'should-retry') { return actual.decision === expected.decision && actual.retryAttempt === expected.retryAttempt } + if (expected.kind === 'url-storage-add') { + return actual.fingerprint === expected.fingerprint && actual.uploadUrl === expected.uploadUrl + } + + if (expected.kind === 'url-storage-find') { + return actual.count === expected.count && actual.fingerprint === expected.fingerprint + } + + if (expected.kind === 'url-storage-remove') { + return actual.urlStorageKey === expected.urlStorageKey + } + return true } @@ -451,6 +478,14 @@ async function startScenarioUpload(scenario, testStack) { options.retryDelays = scenario.input.retryDelays } + if (scenario.input.removeFingerprintOnSuccess != null) { + options.removeFingerprintOnSuccess = scenario.input.removeFingerprintOnSuccess + } + + if (scenario.input.storeFingerprintForResuming != null) { + options.storeFingerprintForResuming = scenario.input.storeFingerprintForResuming + } + if (scenario.input.uploadDataDuringCreation != null) { options.uploadDataDuringCreation = scenario.input.uploadDataDuringCreation } @@ -463,13 +498,27 @@ async function startScenarioUpload(scenario, testStack) { options.uploadUrl = scenario.input.uploadUrl } - if (scenario.input.storedUpload != null) { - options.fingerprint = jasmine - .createSpy('fingerprint') - .and.resolveTo(scenario.input.storedUpload.fingerprint) - options.urlStorage = makeStoredUploadStorage(scenario.input.storedUpload) - } else if (scenario.input.kind === 'readable-stream') { - options.fingerprint = jasmine.createSpy('fingerprint').and.resolveTo(null) + const scenarioFingerprint = + scenario.input.fingerprint !== undefined + ? scenario.input.fingerprint + : scenario.input.storedUpload?.fingerprint + if (scenarioFingerprint !== undefined || scenario.input.kind === 'readable-stream') { + options.fingerprint = jasmine.createSpy('fingerprint').and.callFake(() => { + const fingerprint = scenarioFingerprint ?? null + if (scenarioWantsEvent(scenario, 'fingerprint')) { + observedEvents.push({ fingerprint, kind: 'fingerprint' }) + } + return Promise.resolve(fingerprint) + }) + } + + if ( + scenario.input.storedUpload != null || + scenarioWantsEvent(scenario, 'url-storage-add') || + scenarioWantsEvent(scenario, 'url-storage-find') || + scenarioWantsEvent(scenario, 'url-storage-remove') + ) { + options.urlStorage = makeEventRecordingUrlStorage(scenario.input.storedUpload, observedEvents) } if (scenario.behavior === 'terminate-with-retry') { From 585c0ff72e1dea1b1bf4c1b6df099cf65d7a2bed Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:19:28 +0200 Subject: [PATCH 074/155] Cover TUS relative Location conformance --- test/spec/generated-protocol-contract.js | 79 ++++++++++++++++++- test/spec/test-generated-protocol-contract.js | 1 + 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index c347c10ac..22d276672 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -753,8 +753,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['relativeLocationResolution'], + status: 'covered-by-generated-scenario', }, description: 'Normalize relative Location headers against the request endpoint.', featureId: 'relativeLocationResolution', @@ -1099,6 +1099,81 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'resumeFromPreviousUpload', }, + { + behavior: 'relative-location-resolution', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/files/relative-contract', + }, + events: [ + { + kind: 'upload-url-available', + }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'relativeLocationResolution', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/files/', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['resolve-relative-location'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'relative-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'relativeLocationResolution', + }, { behavior: 'deferred-length-upload', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index a99ac7d2a..635d54909 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -603,6 +603,7 @@ describe('generated TUS protocol contract', () => { 'creationWithUpload', 'uploadBodyHeaders', 'resumeFromPreviousUpload', + 'relativeLocationResolution', 'deferredLengthUpload', 'overridePatchMethod', 'parallelUploadConcat', From c7b5c8d56a6ff2dbafef069c948a9685e24d6dcb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:48:41 +0200 Subject: [PATCH 075/155] Cover TUS input source conformance --- test/spec/generated-protocol-contract.js | 327 +++++++++++++++++- test/spec/test-generated-protocol-contract.js | 90 ++++- 2 files changed, 407 insertions(+), 10 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 22d276672..76dfec059 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -682,8 +682,14 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: [ + 'arrayBufferInput', + 'arrayBufferViewInput', + 'webReadableStreamInput', + 'nodeReadableStreamInput', + 'nodePathInput', + ], + status: 'covered-by-generated-scenario', }, description: 'Support the reference client input/source families across runtimes.', featureId: 'inputSources', @@ -1174,6 +1180,321 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'relativeLocationResolution', }, + { + behavior: 'array-buffer-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/array-buffer-contract', + }, + events: [ + { + inputKind: 'array-buffer', + kind: 'source-open', + size: 11, + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'inputSources', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'array-buffer', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-browser-file'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/array-buffer-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'arrayBufferInput', + }, + { + behavior: 'array-buffer-view-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', + }, + events: [ + { + inputKind: 'array-buffer-view', + kind: 'source-open', + size: 11, + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'inputSources', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'array-buffer-view', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-browser-file'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/array-buffer-view-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'arrayBufferViewInput', + }, + { + behavior: 'web-readable-stream-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/web-stream-contract', + }, + events: [ + { + inputKind: 'web-readable-stream', + kind: 'source-open', + size: null, + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'inputSources', + input: { + chunkSize: 100, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'web-readable-stream', + metadata: { + filename: 'hello.txt', + }, + uploadLengthDeferred: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-web-stream'], + requests: [ + { + absentHeaders: ['Upload-Length'], + headers: { + 'Upload-Defer-Length': '1', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/web-stream-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'webReadableStreamInput', + }, + { + behavior: 'node-readable-stream-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/node-stream-contract', + }, + events: [ + { + inputKind: 'node-readable-stream', + kind: 'source-open', + size: null, + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'inputSources', + input: { + chunkSize: 100, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'node-readable-stream', + metadata: { + filename: 'hello.txt', + }, + uploadLengthDeferred: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-node-stream'], + requests: [ + { + absentHeaders: ['Upload-Length'], + headers: { + 'Upload-Defer-Length': '1', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/node-stream-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + runtimes: ['node'], + scenarioId: 'nodeReadableStreamInput', + }, + { + behavior: 'node-path-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/node-path-contract', + }, + events: [ + { + inputKind: 'node-path-reference', + kind: 'source-open', + size: 11, + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'inputSources', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'node-path-reference', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-node-file'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/node-path-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + runtimes: ['node'], + scenarioId: 'nodePathInput', + }, { behavior: 'deferred-length-upload', completion: { @@ -1186,7 +1507,7 @@ export const tusClientConformanceScenarios = [ chunkSize: 100, content: 'hello world', endpointUrl: 'https://tus.io/uploads', - kind: 'readable-stream', + kind: 'web-readable-stream', metadata: { filename: 'hello.txt', }, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 635d54909..35fea9b57 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -45,6 +45,22 @@ function getDefaultWireVersion() { return versions[0].value } +function getGeneratedConformanceRuntime() { + if (typeof window !== 'undefined' && window.document) { + return 'browser' + } + + if (typeof globalThis.Deno !== 'undefined') { + return 'deno' + } + + return 'node' +} + +function scenarioAppliesToCurrentRuntime(scenario) { + return !scenario.runtimes || scenario.runtimes.includes(getGeneratedConformanceRuntime()) +} + function requestMatchesHeaderVariant(requestHeaders, variant) { return variant.fields .filter((field) => field.required) @@ -125,15 +141,42 @@ function createReadableStream(content) { }) } -function createScenarioInput(input) { +function contentBytes(content) { + return new TextEncoder().encode(content) +} + +async function createScenarioInput(input) { if (input.kind === 'blob') { return getBlob(input.content) } - if (input.kind === 'readable-stream') { + if (input.kind === 'array-buffer') { + const bytes = contentBytes(input.content) + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + } + + if (input.kind === 'array-buffer-view') { + return contentBytes(input.content) + } + + if (input.kind === 'web-readable-stream') { return createReadableStream(input.content) } + if (input.kind === 'node-readable-stream') { + const { Readable } = await import('node:stream') + return Readable.from([Buffer.from(contentBytes(input.content))]) + } + + if (input.kind === 'node-path-reference') { + const { writeFile } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const path = await import('node:path') + const filePath = path.join(tmpdir(), 'tus-js-client-generated-contract-input.bin') + await writeFile(filePath, contentBytes(input.content)) + return { path: filePath } + } + throw new Error(`Unsupported generated TUS scenario input kind: ${input.kind}`) } @@ -179,11 +222,19 @@ function scenarioWantsEvent(scenario, kind) { return scenario.events.some((event) => event.kind === kind) } -function makeEventRecordingFileReader(fileReader, observedEvents) { +function makeEventRecordingFileReader(fileReader, scenario, observedEvents) { return { async openFile(input, chunkSize) { const source = await fileReader.openFile(input, chunkSize) + if (scenarioWantsEvent(scenario, 'source-open')) { + observedEvents.push({ + inputKind: scenario.input.kind, + kind: 'source-open', + size: source.size, + }) + } + return { get size() { return source.size @@ -262,6 +313,10 @@ function eventMatchesExpectation(scenario, actual, expected) { ) } + if (expected.kind === 'source-open') { + return actual.inputKind === expected.inputKind && actual.size === expected.size + } + if (expected.kind === 'fingerprint') { return actual.fingerprint === expected.fingerprint } @@ -433,8 +488,12 @@ async function startScenarioUpload(scenario, testStack) { } } - if (scenarioWantsEvent(scenario, 'source-close')) { - options.fileReader = makeEventRecordingFileReader(defaultOptions.fileReader, observedEvents) + if (scenarioWantsEvent(scenario, 'source-open') || scenarioWantsEvent(scenario, 'source-close')) { + options.fileReader = makeEventRecordingFileReader( + defaultOptions.fileReader, + scenario, + observedEvents, + ) } if (scenarioWantsEvent(scenario, 'should-retry')) { @@ -478,6 +537,10 @@ async function startScenarioUpload(scenario, testStack) { options.retryDelays = scenario.input.retryDelays } + if (scenario.input.uploadSize != null) { + options.uploadSize = scenario.input.uploadSize + } + if (scenario.input.removeFingerprintOnSuccess != null) { options.removeFingerprintOnSuccess = scenario.input.removeFingerprintOnSuccess } @@ -502,7 +565,11 @@ async function startScenarioUpload(scenario, testStack) { scenario.input.fingerprint !== undefined ? scenario.input.fingerprint : scenario.input.storedUpload?.fingerprint - if (scenarioFingerprint !== undefined || scenario.input.kind === 'readable-stream') { + if ( + scenarioFingerprint !== undefined || + scenario.input.kind === 'web-readable-stream' || + scenario.input.kind === 'node-readable-stream' + ) { options.fingerprint = jasmine.createSpy('fingerprint').and.callFake(() => { const fingerprint = scenarioFingerprint ?? null if (scenarioWantsEvent(scenario, 'fingerprint')) { @@ -534,7 +601,7 @@ async function startScenarioUpload(scenario, testStack) { } } - upload = new Upload(createScenarioInput(scenario.input), options) + upload = new Upload(await createScenarioInput(scenario.input), options) if (scenario.behavior === 'resume-from-previous-upload') { const previousUploads = await upload.findPreviousUploads() @@ -592,6 +659,10 @@ async function runGeneratedConformanceScenario(scenario) { describe('generated TUS protocol contract', () => { for (const scenario of tusClientConformanceScenarios) { + if (!scenarioAppliesToCurrentRuntime(scenario)) { + continue + } + it(`drives ${scenario.scenarioId} from the generated contract`, async () => { await runGeneratedConformanceScenario(getClientConformanceScenario(scenario.scenarioId)) }) @@ -604,6 +675,11 @@ describe('generated TUS protocol contract', () => { 'uploadBodyHeaders', 'resumeFromPreviousUpload', 'relativeLocationResolution', + 'arrayBufferInput', + 'arrayBufferViewInput', + 'webReadableStreamInput', + 'nodeReadableStreamInput', + 'nodePathInput', 'deferredLengthUpload', 'overridePatchMethod', 'parallelUploadConcat', From e0dc98c4be47ab632884f579d144878216261820 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 18:10:38 +0200 Subject: [PATCH 076/155] Cover TUS retry state conformance --- lib/protocol_generated.ts | 65 ++++++++++-- lib/upload.ts | 7 +- test/spec/generated-protocol-contract.js | 78 ++++++++++++++- test/spec/test-generated-protocol-contract.js | 98 +++++++++++++------ 4 files changed, 202 insertions(+), 46 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 657cd2ac9..f00de28fc 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -400,18 +400,31 @@ export const TUS_FLOW_POLICY = { beforeRequest: 'before-transport-send', }, retry: { + attemptCounter: { + increment: 'after-retry-scheduled', + reset: 'when-offset-advanced-since-last-retry', + }, customDecision: 'custom-callback-before-default-decision', + delaySource: 'retry-delays-indexed-by-attempt', defaultDecision: 'retryable-status-and-online', error: { retryableWhen: 'request-context-present', }, evaluationTrigger: 'generated-plan-evaluate-policy', + failure: { + exhaustedDelays: 'emit-error', + nonRetryableError: 'emit-error', + policyRejected: 'emit-error', + }, onlineSignal: { defaultWhenUnavailable: true, offlineWhenPlatformOnlineIsFalse: true, source: 'sdk-platform-online-status', }, - timer: 'sdk-platform-timer', + timer: { + restart: 'start-upload-after-delay', + source: 'sdk-platform-timer', + }, }, }, urlStorage: { @@ -671,10 +684,11 @@ export type TusRetryAfterErrorPlan = | { action: 'emitError'; retryAttempt: number } | { action: 'evaluatePolicy'; retryAttempt: number } | { - action: 'retry' + action: 'scheduleRetry' delay: number nextRetryAttempt: number offsetBeforeRetry: number + remainingRetryDelays: number[] retryAttempt: number } @@ -1119,16 +1133,50 @@ function tusAssertRequestLifecyclePolicySupported(): void { throw new Error(`tus: unsupported default retry decision ${policy.retry.defaultDecision}`) } + if (policy.retry.onlineSignal.source !== 'sdk-platform-online-status') { + throw new Error(`tus: unsupported retry online signal ${policy.retry.onlineSignal.source}`) + } + if (policy.retry.error.retryableWhen !== 'request-context-present') { throw new Error(`tus: unsupported retryable error policy ${policy.retry.error.retryableWhen}`) } - if (policy.retry.onlineSignal.source !== 'sdk-platform-online-status') { - throw new Error(`tus: unsupported retry online signal ${policy.retry.onlineSignal.source}`) + if (policy.retry.failure.nonRetryableError !== 'emit-error') { + throw new Error( + `tus: unsupported non-retryable-error policy ${policy.retry.failure.nonRetryableError}`, + ) + } + + if (policy.retry.failure.exhaustedDelays !== 'emit-error') { + throw new Error( + `tus: unsupported exhausted retry delay policy ${policy.retry.failure.exhaustedDelays}`, + ) } - if (policy.retry.timer !== 'sdk-platform-timer') { - throw new Error(`tus: unsupported retry timer policy ${policy.retry.timer}`) + if (policy.retry.failure.policyRejected !== 'emit-error') { + throw new Error(`tus: unsupported rejected retry policy ${policy.retry.failure.policyRejected}`) + } + + if (policy.retry.delaySource !== 'retry-delays-indexed-by-attempt') { + throw new Error(`tus: unsupported retry delay source ${policy.retry.delaySource}`) + } + + if (policy.retry.attemptCounter.reset !== 'when-offset-advanced-since-last-retry') { + throw new Error(`tus: unsupported retry reset policy ${policy.retry.attemptCounter.reset}`) + } + + if (policy.retry.attemptCounter.increment !== 'after-retry-scheduled') { + throw new Error( + `tus: unsupported retry increment policy ${policy.retry.attemptCounter.increment}`, + ) + } + + if (policy.retry.timer.source !== 'sdk-platform-timer') { + throw new Error(`tus: unsupported retry timer source ${policy.retry.timer.source}`) + } + + if (policy.retry.timer.restart !== 'start-upload-after-delay') { + throw new Error(`tus: unsupported retry timer restart ${policy.retry.timer.restart}`) } } @@ -2507,6 +2555,8 @@ export function tusShouldResetRetryAttempt({ offset: number offsetBeforeRetry: number }): boolean { + tusAssertRequestLifecyclePolicySupported() + return offset > offsetBeforeRetry } @@ -2542,10 +2592,11 @@ export function tusPlanRetryAfterError({ } return { - action: 'retry', + action: 'scheduleRetry', delay: retryDelays[effectiveRetryAttempt], nextRetryAttempt: effectiveRetryAttempt + 1, offsetBeforeRetry: offset, + remainingRetryDelays: [...retryDelays.slice(effectiveRetryAttempt + 1)], retryAttempt: effectiveRetryAttempt, } } diff --git a/lib/upload.ts b/lib/upload.ts index 72ecd1b02..2c6795594 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -520,7 +520,7 @@ export class BaseUpload { this._retryAttempt = retryPlan.retryAttempt } - if (retryPlan.action === 'retry') { + if (retryPlan.action === 'scheduleRetry') { this._retryAttempt = retryPlan.nextRetryAttempt this._offsetBeforeRetry = retryPlan.offsetBeforeRetry @@ -1212,14 +1212,13 @@ export async function terminate(url: string, options: UploadOptions): Promise terminatePromise, upload } + return { + observedEvents, + onError, + onSuccess, + restoreRetryTimerRecorder, + terminatePromise: () => terminatePromise, + upload, + } +} + +function installRetryTimerRecorder(scenario, observedEvents) { + if (!scenarioWantsEvent(scenario, 'retry-schedule')) { + return () => {} + } + + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = (handler, delay, ...args) => { + observedEvents.push({ delay, kind: 'retry-schedule' }) + return originalSetTimeout(handler, delay, ...args) + } + + return () => { + globalThis.setTimeout = originalSetTimeout + } } async function runGeneratedConformanceScenario(scenario) { @@ -619,42 +647,52 @@ async function runGeneratedConformanceScenario(scenario) { expect(feature.primitives).toEqual(jasmine.arrayContaining(scenario.primitives)) const testStack = new TestHttpStack() - const { observedEvents, onError, onSuccess, terminatePromise, upload } = - await startScenarioUpload(scenario, testStack) - - for (const [requestIndex, request] of scenario.requests.entries()) { - const req = await testStack.nextRequest() - expectScenarioRequest(req, scenario, request) - - if (request.abort) { - await abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload) - } else if (!request.response) { - throw new Error( - `Generated scenario ${scenario.scenarioId} request ${requestIndex} has no response and is not marked abort`, - ) + const { + observedEvents, + onError, + onSuccess, + restoreRetryTimerRecorder, + terminatePromise, + upload, + } = await startScenarioUpload(scenario, testStack) + + try { + for (const [requestIndex, request] of scenario.requests.entries()) { + const req = await testStack.nextRequest() + expectScenarioRequest(req, scenario, request) + + if (request.abort) { + await abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload) + } else if (!request.response) { + throw new Error( + `Generated scenario ${scenario.scenarioId} request ${requestIndex} has no response and is not marked abort`, + ) + } } - } - if (scenario.completion.kind === 'aborted') { - expect(onSuccess).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() - expectScenarioEvents(scenario, observedEvents) - return - } + if (scenario.completion.kind === 'aborted') { + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) + return + } + + if (scenario.completion.kind === 'terminated') { + await terminatePromise() + expect(upload.url).toBe(scenario.completion.uploadUrl) + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) + return + } - if (scenario.completion.kind === 'terminated') { - await terminatePromise() + await onSuccess.toBeCalled() expect(upload.url).toBe(scenario.completion.uploadUrl) - expect(onSuccess).not.toHaveBeenCalled() expect(onError).not.toHaveBeenCalled() expectScenarioEvents(scenario, observedEvents) - return + } finally { + restoreRetryTimerRecorder() } - - await onSuccess.toBeCalled() - expect(upload.url).toBe(scenario.completion.uploadUrl) - expect(onError).not.toHaveBeenCalled() - expectScenarioEvents(scenario, observedEvents) } describe('generated TUS protocol contract', () => { From cebe2dcf53e37df184d032fc9f61b2cccf2f689f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 20:09:07 +0200 Subject: [PATCH 077/155] Cover TUS URL storage backends --- test/spec/generated-protocol-contract.js | 177 ++++++++++++++++++++++- test/spec/helpers/assertUrlStorage.js | 131 +++++++++++------ test/spec/test-browser-specific.js | 11 +- test/spec/test-node-specific.js | 8 +- 4 files changed, 274 insertions(+), 53 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b2871cdac..7db7f69fb 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -746,8 +746,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['webStorageUrlStorageBackend', 'fileUrlStorageBackend'], + status: 'covered-by-generated-scenario', }, description: 'Support browser and file-backed URL storage implementations.', featureId: 'urlStorageBackends', @@ -1999,3 +1999,176 @@ export const tusClientConformanceScenarios = [ scenarioId: 'terminateWithRetry', }, ] + +export const tusClientUrlStorageConformanceScenarios = [ + { + actions: [ + { + kind: 'assert-empty', + }, + { + expectedKeyPrefix: 'tus::contract-storage-a::', + fingerprint: 'contract-storage-a', + keyRef: 'a1', + kind: 'add-upload', + upload: { + id: 1, + metadata: { + filename: 'a1.txt', + }, + size: 11, + uploadUrl: 'https://tus.io/uploads/storage-a1', + }, + }, + { + expectedKeyPrefix: 'tus::contract-storage-a::', + fingerprint: 'contract-storage-a', + keyRef: 'a2', + kind: 'add-upload', + upload: { + id: 2, + metadata: { + filename: 'a2.txt', + }, + size: 12, + uploadUrl: 'https://tus.io/uploads/storage-a2', + }, + }, + { + expectedKeyPrefix: 'tus::contract-storage-b::', + fingerprint: 'contract-storage-b', + keyRef: 'b1', + kind: 'add-upload', + upload: { + id: 3, + metadata: { + filename: 'b1.txt', + }, + size: 13, + uploadUrl: 'https://tus.io/uploads/storage-b1', + }, + }, + { + expectedKeyRefs: ['a1', 'a2'], + fingerprint: 'contract-storage-a', + kind: 'find-by-fingerprint', + }, + { + expectedKeyRefs: ['b1'], + fingerprint: 'contract-storage-b', + kind: 'find-by-fingerprint', + }, + { + expectedKeyRefs: ['a1', 'a2', 'b1'], + kind: 'find-all', + }, + { + keyRef: 'a2', + kind: 'remove-upload', + }, + { + keyRef: 'b1', + kind: 'remove-upload', + }, + { + expectedKeyRefs: ['a1'], + fingerprint: 'contract-storage-a', + kind: 'find-by-fingerprint', + }, + { + expectedKeyRefs: [], + fingerprint: 'contract-storage-b', + kind: 'find-by-fingerprint', + }, + ], + backend: 'web-storage', + featureId: 'urlStorageBackends', + runtimes: ['browser'], + scenarioId: 'webStorageUrlStorageBackend', + }, + { + actions: [ + { + kind: 'assert-empty', + }, + { + expectedKeyPrefix: 'tus::contract-storage-a::', + fingerprint: 'contract-storage-a', + keyRef: 'a1', + kind: 'add-upload', + upload: { + id: 1, + metadata: { + filename: 'a1.txt', + }, + size: 11, + uploadUrl: 'https://tus.io/uploads/storage-a1', + }, + }, + { + expectedKeyPrefix: 'tus::contract-storage-a::', + fingerprint: 'contract-storage-a', + keyRef: 'a2', + kind: 'add-upload', + upload: { + id: 2, + metadata: { + filename: 'a2.txt', + }, + size: 12, + uploadUrl: 'https://tus.io/uploads/storage-a2', + }, + }, + { + expectedKeyPrefix: 'tus::contract-storage-b::', + fingerprint: 'contract-storage-b', + keyRef: 'b1', + kind: 'add-upload', + upload: { + id: 3, + metadata: { + filename: 'b1.txt', + }, + size: 13, + uploadUrl: 'https://tus.io/uploads/storage-b1', + }, + }, + { + expectedKeyRefs: ['a1', 'a2'], + fingerprint: 'contract-storage-a', + kind: 'find-by-fingerprint', + }, + { + expectedKeyRefs: ['b1'], + fingerprint: 'contract-storage-b', + kind: 'find-by-fingerprint', + }, + { + expectedKeyRefs: ['a1', 'a2', 'b1'], + kind: 'find-all', + }, + { + keyRef: 'a2', + kind: 'remove-upload', + }, + { + keyRef: 'b1', + kind: 'remove-upload', + }, + { + expectedKeyRefs: ['a1'], + fingerprint: 'contract-storage-a', + kind: 'find-by-fingerprint', + }, + { + expectedKeyRefs: [], + fingerprint: 'contract-storage-b', + kind: 'find-by-fingerprint', + }, + ], + backend: 'file-storage', + featureId: 'urlStorageBackends', + runtimes: ['deno', 'node'], + scenarioId: 'fileUrlStorageBackend', + }, +] diff --git a/test/spec/helpers/assertUrlStorage.js b/test/spec/helpers/assertUrlStorage.js index 5440dabf3..a6c06a3bc 100644 --- a/test/spec/helpers/assertUrlStorage.js +++ b/test/spec/helpers/assertUrlStorage.js @@ -1,50 +1,87 @@ -export async function assertUrlStorage(urlStorage) { - // In the beginning of the test, the storage should be empty. - let result = await urlStorage.findAllUploads() - expect(result).toEqual([]) - - // Add a few uploads into the storage - const key1 = await urlStorage.addUpload('fingerprintA', { id: 1 }) - const key2 = await urlStorage.addUpload('fingerprintA', { id: 2 }) - const key3 = await urlStorage.addUpload('fingerprintB', { id: 3 }) - - expect(/^tus::fingerprintA::/.test(key1)).toBe(true) - expect(/^tus::fingerprintA::/.test(key2)).toBe(true) - expect(/^tus::fingerprintB::/.test(key3)).toBe(true) - - // Query the just stored uploads individually - result = await urlStorage.findUploadsByFingerprint('fingerprintA') - sort(result) - expect(result).toEqual([ - { id: 1, urlStorageKey: key1 }, - { id: 2, urlStorageKey: key2 }, - ]) - - result = await urlStorage.findUploadsByFingerprint('fingerprintB') - sort(result) - expect(result).toEqual([{ id: 3, urlStorageKey: key3 }]) - - // Check that we can retrieve all stored uploads - result = await urlStorage.findAllUploads() - sort(result) - expect(result).toEqual([ - { id: 1, urlStorageKey: key1 }, - { id: 2, urlStorageKey: key2 }, - { id: 3, urlStorageKey: key3 }, - ]) - - // Check that it can remove an upload and will not return it back - await urlStorage.removeUpload(key2) - await urlStorage.removeUpload(key3) - - result = await urlStorage.findUploadsByFingerprint('fingerprintA') - expect(result).toEqual([{ id: 1, urlStorageKey: key1 }]) - - result = await urlStorage.findUploadsByFingerprint('fingerprintB') - expect(result).toEqual([]) +export async function assertUrlStorage(urlStorage, scenario) { + const keyRefs = new Map() + const expectedUploads = new Map() + + for (const action of scenario.actions) { + if (action.kind === 'assert-empty') { + expect(await urlStorage.findAllUploads()).toEqual([]) + continue + } + + if (action.kind === 'add-upload') { + const upload = clone(action.upload) + const key = await urlStorage.addUpload(action.fingerprint, upload) + expect(key.startsWith(action.expectedKeyPrefix)).toBe(true) + keyRefs.set(action.keyRef, key) + expectedUploads.set(action.keyRef, { ...clone(action.upload), urlStorageKey: key }) + continue + } + + if (action.kind === 'find-by-fingerprint') { + expectStoredUploads( + await urlStorage.findUploadsByFingerprint(action.fingerprint), + expectedUploadsForRefs(expectedUploads, action.expectedKeyRefs), + ) + continue + } + + if (action.kind === 'find-all') { + expectStoredUploads( + await urlStorage.findAllUploads(), + expectedUploadsForRefs(expectedUploads, action.expectedKeyRefs), + ) + continue + } + + if (action.kind === 'remove-upload') { + const key = keyRefs.get(action.keyRef) + if (key == null) { + throw new Error(`Generated URL storage scenario references unknown key: ${action.keyRef}`) + } + + await urlStorage.removeUpload(key) + expectedUploads.delete(action.keyRef) + continue + } + + throw new Error(`Unsupported generated URL storage scenario action: ${action.kind}`) + } +} + +export function findUrlStorageScenario(scenarios, scenarioId) { + const scenario = scenarios.find((candidate) => candidate.scenarioId === scenarioId) + if (!scenario) { + throw new Error(`Missing generated URL storage conformance scenario: ${scenarioId}`) + } + + return scenario +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)) } -// Sort the results from the URL storage since the order in not deterministic. -function sort(result) { - result.sort((a, b) => a.id - b.id) +function expectedUploadsForRefs(expectedUploads, refs) { + return refs.map((ref) => { + const upload = expectedUploads.get(ref) + if (!upload) { + throw new Error(`Generated URL storage scenario references unknown expected upload: ${ref}`) + } + + return upload + }) +} + +function expectStoredUploads(actual, expected) { + expect(sortStoredUploads(actual)).toEqual(sortStoredUploads(expected)) +} + +function sortStoredUploads(result) { + return [...result].sort((a, b) => { + if (a.id !== b.id) { + return a.id - b.id + } + + return String(a.urlStorageKey).localeCompare(String(b.urlStorageKey)) + }) } diff --git a/test/spec/test-browser-specific.js b/test/spec/test-browser-specific.js index 621cffc02..a91779678 100644 --- a/test/spec/test-browser-specific.js +++ b/test/spec/test-browser-specific.js @@ -1,5 +1,6 @@ import { defaultOptions, Upload } from 'tus-js-client' -import { assertUrlStorage } from './helpers/assertUrlStorage.js' +import { tusClientUrlStorageConformanceScenarios } from './generated-protocol-contract.js' +import { assertUrlStorage, findUrlStorageScenario } from './helpers/assertUrlStorage.js' import { TestHttpStack, wait, waitableFunction } from './helpers/utils.js' describe('tus', () => { @@ -370,7 +371,13 @@ describe('tus', () => { describe('#LocalStorageUrlStorage', () => { it('should allow storing and retrieving uploads', async () => { - await assertUrlStorage(defaultOptions.urlStorage) + await assertUrlStorage( + defaultOptions.urlStorage, + findUrlStorageScenario( + tusClientUrlStorageConformanceScenarios, + 'webStorageUrlStorageBackend', + ), + ) }) }) }) diff --git a/test/spec/test-node-specific.js b/test/spec/test-node-specific.js index dabf21509..1c6c4c87b 100644 --- a/test/spec/test-node-specific.js +++ b/test/spec/test-node-specific.js @@ -11,7 +11,8 @@ import { canStoreURLs, Upload } from 'tus-js-client' import { FileUrlStorage } from 'tus-js-client/node/FileUrlStorage' import { NodeHttpStack } from 'tus-js-client/node/NodeHttpStack' import { NodeStreamFileSource } from 'tus-js-client/node/sources/NodeStreamFileSource' -import { assertUrlStorage } from './helpers/assertUrlStorage.js' +import { tusClientUrlStorageConformanceScenarios } from './generated-protocol-contract.js' +import { assertUrlStorage, findUrlStorageScenario } from './helpers/assertUrlStorage.js' import { TestHttpStack, waitableFunction } from './helpers/utils.js' describe('tus', () => { @@ -409,7 +410,10 @@ describe('tus', () => { it('should allow storing and retrieving uploads', async () => { const storagePath = temp.path() const storage = new FileUrlStorage(storagePath) - await assertUrlStorage(storage) + await assertUrlStorage( + storage, + findUrlStorageScenario(tusClientUrlStorageConformanceScenarios, 'fileUrlStorageBackend'), + ) }) }) From 977ae9728df0aa47b5f692b718b696dc282282b5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 21:06:25 +0200 Subject: [PATCH 078/155] Cover protocol selection conformance --- test/spec/generated-protocol-contract.js | 152 +++++++++++++++++- test/spec/test-generated-protocol-contract.js | 14 ++ 2 files changed, 164 insertions(+), 2 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 7db7f69fb..b0d78efce 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -768,8 +768,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['ietfDraft05CreationWithUpload', 'ietfDraft03ResumeWithoutKnownLength'], + status: 'covered-by-generated-scenario', }, description: 'Select between tus v1 and supported IETF draft client protocol modes.', featureId: 'protocolVersionSelection', @@ -988,6 +988,154 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'creationWithUpload', }, + { + behavior: 'creation-with-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', + }, + events: [ + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + }, + { + kind: 'upload-url-available', + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'protocolVersionSelection', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + protocol: 'ietf-draft-05', + uploadDataDuringCreation: true, + }, + operationIds: ['createTusUpload'], + primitives: ['select-client-protocol'], + requests: [ + { + absentHeaders: ['Tus-Resumable'], + bodySize: 11, + headerMode: 'exact', + headers: { + 'Content-Type': 'application/partial-upload', + 'Upload-Complete': '?1', + 'Upload-Draft-Interop-Version': '6', + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headerMode: 'exact', + headers: { + Location: 'https://tus.io/uploads/ietf-draft-05-contract', + 'Upload-Offset': '11', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + ], + scenarioId: 'ietfDraft05CreationWithUpload', + }, + { + behavior: 'upload-body-headers', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + events: [ + { + kind: 'upload-url-available', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 6, + kind: 'chunk-complete', + }, + { + kind: 'success', + }, + { + kind: 'source-close', + }, + ], + featureId: 'protocolVersionSelection', + input: { + chunkSize: 6, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + protocol: 'ietf-draft-03', + uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['select-client-protocol'], + requests: [ + { + absentHeaders: ['Tus-Resumable'], + headerMode: 'exact', + headers: { + 'Upload-Draft-Interop-Version': '5', + }, + operationId: 'getTusUploadOffset', + response: { + headerMode: 'exact', + headers: { + 'Upload-Offset': '5', + }, + statusCode: 200, + }, + url: 'upload', + }, + { + absentHeaders: ['Content-Type', 'Tus-Resumable'], + bodySize: 6, + headerMode: 'exact', + headers: { + 'Upload-Complete': '?1', + 'Upload-Draft-Interop-Version': '5', + 'Upload-Offset': '5', + }, + operationId: 'patchTusUpload', + response: { + headerMode: 'exact', + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'ietfDraft03ResumeWithoutKnownLength', + }, { behavior: 'upload-body-headers', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3b93509f8..3e3239fe2 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -70,6 +70,10 @@ function requestMatchesHeaderVariant(requestHeaders, variant) { function expectRequestMatchesOperation(req, operation, request) { expect(req.method).toBe(request.method ?? operation.method) + if (request.headerMode === 'exact') { + return + } + const expectedContentType = request.headers?.['Content-Type'] ?? operation.request.contentType if (expectedContentType) { expect(req.requestHeaders['Content-Type']).toBe(expectedContentType) @@ -117,6 +121,10 @@ function responseHeadersFor(response, overrides = {}) { } function scenarioResponseHeadersFor(operation, response) { + if (response.headerMode === 'exact') { + return response.headers ?? {} + } + const operationResponse = getOperationResponse(operation, response.statusCode) if (!operationResponse) { return response.headers ?? {} @@ -542,6 +550,10 @@ async function startScenarioUpload(scenario, testStack) { options.retryDelays = scenario.input.retryDelays } + if (scenario.input.protocol != null) { + options.protocol = scenario.input.protocol + } + if (scenario.input.uploadSize != null) { options.uploadSize = scenario.input.uploadSize } @@ -710,6 +722,8 @@ describe('generated TUS protocol contract', () => { expect(tusClientConformanceScenarios.map((scenario) => scenario.scenarioId)).toEqual([ 'singleUploadLifecycle', 'creationWithUpload', + 'ietfDraft05CreationWithUpload', + 'ietfDraft03ResumeWithoutKnownLength', 'uploadBodyHeaders', 'resumeFromPreviousUpload', 'relativeLocationResolution', From e22bb0cd19cb7ed5d07e6006972fd35b76c9bf6f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 22:26:26 +0200 Subject: [PATCH 079/155] Cover start validation conformance --- test/spec/generated-protocol-contract.js | 209 +++++++++++++++++- test/spec/test-generated-protocol-contract.js | 28 +++ 2 files changed, 235 insertions(+), 2 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b0d78efce..fd1172b82 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -802,8 +802,18 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: [ + 'startValidationMissingInput', + 'startValidationMissingEndpointOrUploadUrl', + 'startValidationUnsupportedProtocol', + 'startValidationRetryDelaysNotArray', + 'startValidationParallelUploadsWithUploadUrl', + 'startValidationParallelUploadsWithUploadSize', + 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelBoundariesWithoutParallelUploads', + 'startValidationParallelBoundariesLengthMismatch', + ], + status: 'covered-by-generated-scenario', }, description: 'Validate option combinations before starting runtime work.', featureId: 'startOptionValidation', @@ -1136,6 +1146,201 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'ietfDraft03ResumeWithoutKnownLength', }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: no file or stream to upload provided', + reason: 'missingInput', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: '', + endpointUrl: 'https://tus.io/uploads', + kind: 'none', + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationMissingInput', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: neither an endpoint or an upload URL is provided', + reason: 'missingEndpointOrUploadUrl', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationMissingEndpointOrUploadUrl', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: unsupported protocol tus-v9', + reason: 'unsupportedProtocol', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + protocol: 'tus-v9', + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationUnsupportedProtocol', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: the `retryDelays` option must either be an array or null', + reason: 'retryDelaysNotArray', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + rawOptions: { + retryDelays: 44, + }, + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationRetryDelaysNotArray', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + reason: 'parallelUploadsWithUploadUrl', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + parallelUploads: 2, + uploadUrl: 'https://tus.io/uploads/start-validation-upload-url', + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationParallelUploadsWithUploadUrl', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + reason: 'parallelUploadsWithUploadSize', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + parallelUploads: 2, + uploadSize: 11, + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationParallelUploadsWithUploadSize', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + reason: 'parallelUploadsWithDeferredLength', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + parallelUploads: 2, + uploadLengthDeferred: true, + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationParallelUploadsWithDeferredLength', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: + 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + reason: 'parallelBoundariesWithoutParallelUploads', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + parallelUploadBoundaries: [ + { + end: 5, + start: 0, + }, + ], + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: + 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + reason: 'parallelBoundariesLengthMismatch', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + parallelUploadBoundaries: [ + { + end: 5, + start: 0, + }, + ], + parallelUploads: 2, + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationParallelBoundariesLengthMismatch', + }, { behavior: 'upload-body-headers', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3e3239fe2..79b63f1a0 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -154,6 +154,10 @@ function contentBytes(content) { } async function createScenarioInput(input) { + if (input.kind === 'none') { + return null + } + if (input.kind === 'blob') { return getBlob(input.content) } @@ -546,6 +550,10 @@ async function startScenarioUpload(scenario, testStack) { options.parallelUploads = scenario.input.parallelUploads } + if (scenario.input.parallelUploadBoundaries != null) { + options.parallelUploadBoundaries = scenario.input.parallelUploadBoundaries + } + if (scenario.input.retryDelays != null) { options.retryDelays = scenario.input.retryDelays } @@ -578,6 +586,8 @@ async function startScenarioUpload(scenario, testStack) { options.uploadUrl = scenario.input.uploadUrl } + Object.assign(options, scenario.input.rawOptions ?? {}) + const scenarioFingerprint = scenario.input.fingerprint !== undefined ? scenario.input.fingerprint @@ -698,6 +708,15 @@ async function runGeneratedConformanceScenario(scenario) { return } + if (scenario.completion.kind === 'error') { + const err = await onError.toBeCalled() + expect(err.message).toBe(scenario.completion.message) + expect(onSuccess).not.toHaveBeenCalled() + expect(await Promise.race([testStack.nextRequest(), wait(0)])).toBe('timed out') + expectScenarioEvents(scenario, observedEvents) + return + } + await onSuccess.toBeCalled() expect(upload.url).toBe(scenario.completion.uploadUrl) expect(onError).not.toHaveBeenCalled() @@ -724,6 +743,15 @@ describe('generated TUS protocol contract', () => { 'creationWithUpload', 'ietfDraft05CreationWithUpload', 'ietfDraft03ResumeWithoutKnownLength', + 'startValidationMissingInput', + 'startValidationMissingEndpointOrUploadUrl', + 'startValidationUnsupportedProtocol', + 'startValidationRetryDelaysNotArray', + 'startValidationParallelUploadsWithUploadUrl', + 'startValidationParallelUploadsWithUploadSize', + 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelBoundariesWithoutParallelUploads', + 'startValidationParallelBoundariesLengthMismatch', 'uploadBodyHeaders', 'resumeFromPreviousUpload', 'relativeLocationResolution', From a6d04578675bff75bc38af687be275fed665da70 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 23:10:16 +0200 Subject: [PATCH 080/155] Cover detailed error conformance --- test/spec/generated-protocol-contract.js | 87 ++++++++++++++++++- test/spec/test-generated-protocol-contract.js | 11 ++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index fd1172b82..c066ca0e9 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -829,8 +829,8 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: [], - status: 'needs-generated-scenario', + scenarioIds: ['detailedCreateResponseError', 'detailedCreateRequestError'], + status: 'covered-by-generated-scenario', }, description: 'Attach request, response, status, body, and request ID context to errors.', featureId: 'detailedErrors', @@ -1341,6 +1341,89 @@ export const tusClientConformanceScenarios = [ requests: [], scenarioId: 'startValidationParallelBoundariesLengthMismatch', }, + { + behavior: 'detailed-error', + completion: { + kind: 'error', + message: + 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', + reason: 'unexpectedCreateResponse', + }, + events: [], + featureId: 'detailedErrors', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + headers: { + 'X-Request-ID': 'contract-request-id', + }, + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + rawOptions: { + retryDelays: null, + }, + }, + operationIds: ['createTusUpload'], + primitives: ['report-detailed-errors'], + requests: [ + { + headers: { + 'Upload-Length': '11', + 'X-Request-ID': 'contract-request-id', + }, + operationId: 'createTusUpload', + response: { + body: 'server_error', + statusCode: 500, + }, + url: 'endpoint', + }, + ], + scenarioId: 'detailedCreateResponseError', + }, + { + behavior: 'detailed-error', + completion: { + kind: 'error', + message: + 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', + reason: 'createUploadRequestFailed', + }, + events: [], + featureId: 'detailedErrors', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + headers: { + 'X-Request-ID': 'contract-request-id', + }, + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + rawOptions: { + retryDelays: null, + }, + }, + operationIds: ['createTusUpload'], + primitives: ['report-detailed-errors'], + requests: [ + { + error: { + message: 'socket down', + }, + headers: { + 'Upload-Length': '11', + 'X-Request-ID': 'contract-request-id', + }, + operationId: 'createTusUpload', + url: 'endpoint', + }, + ], + scenarioId: 'detailedCreateRequestError', + }, { behavior: 'upload-body-headers', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 79b63f1a0..3aab5436a 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -422,6 +422,7 @@ function expectScenarioRequest(req, scenario, request) { req.respondWith({ status: request.response.statusCode, responseHeaders: scenarioResponseHeadersFor(operation, request.response), + responseText: request.response.body ?? '', }) } @@ -542,6 +543,10 @@ async function startScenarioUpload(scenario, testStack) { options.metadataForPartialUploads = scenario.input.metadataForPartialUploads } + if (scenario.input.headers != null) { + options.headers = scenario.input.headers + } + if (scenario.input.overridePatchMethod != null) { options.overridePatchMethod = scenario.input.overridePatchMethod } @@ -685,9 +690,11 @@ async function runGeneratedConformanceScenario(scenario) { if (request.abort) { await abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload) + } else if (request.error) { + req.responseError(new Error(request.error.message)) } else if (!request.response) { throw new Error( - `Generated scenario ${scenario.scenarioId} request ${requestIndex} has no response and is not marked abort`, + `Generated scenario ${scenario.scenarioId} request ${requestIndex} has no response, error, or abort`, ) } } @@ -752,6 +759,8 @@ describe('generated TUS protocol contract', () => { 'startValidationParallelUploadsWithDeferredLength', 'startValidationParallelBoundariesWithoutParallelUploads', 'startValidationParallelBoundariesLengthMismatch', + 'detailedCreateResponseError', + 'detailedCreateRequestError', 'uploadBodyHeaders', 'resumeFromPreviousUpload', 'relativeLocationResolution', From 5779fd4c9d022290c988e1621bd6e0983ba09c2c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 21:22:23 +0200 Subject: [PATCH 081/155] Converge generated TUS conformance --- lib/protocol_generated.ts | 13 + lib/upload.ts | 14 + test/spec/generated-protocol-contract.js | 592 +++++++++++++++++- test/spec/test-generated-protocol-contract.js | 15 +- 4 files changed, 623 insertions(+), 11 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index f00de28fc..ce7a91bc2 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -197,6 +197,7 @@ export const TUS_FLOW_POLICY = { ], suppressErrorAfterAbort: true, terminateUpload: 'when-requested-and-upload-url-known', + terminateUploadContext: 'detached-from-aborted-request', }, detailedErrors: { causedByTemplate: ', caused by {cause}', @@ -338,6 +339,8 @@ export const TUS_FLOW_POLICY = { parallelUploadMissingSize: 'tus: Expected _size to be set', parallelUploadsWithDeferredLength: 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + parallelUploadsWithUploadDataDuringCreation: + 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', parallelUploadsWithUploadSize: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', parallelUploadsWithUploadUrl: @@ -483,6 +486,7 @@ export type TusUploadStartValidationReason = | 'parallelUploadsWithUploadUrl' | 'parallelUploadsWithUploadSize' | 'parallelUploadsWithDeferredLength' + | 'parallelUploadsWithUploadDataDuringCreation' | 'parallelBoundariesWithoutParallelUploads' | 'parallelBoundariesLengthMismatch' @@ -500,6 +504,7 @@ export interface TusUploadStartValidationInput { parallelUploads: number protocol: string retryDelays: unknown + uploadDataDuringCreation: boolean uploadLengthDeferred: boolean } @@ -886,6 +891,7 @@ export function tusValidateUploadStart({ parallelUploads, protocol, retryDelays, + uploadDataDuringCreation, uploadLengthDeferred, }: TusUploadStartValidationInput): TusUploadStartValidationResult { if (!hasFile) { @@ -934,6 +940,13 @@ export function tusValidateUploadStart({ TUS_FLOW_POLICY.messages.parallelUploadsWithDeferredLength, ) } + + if (uploadDataDuringCreation) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadDataDuringCreation', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadDataDuringCreation, + ) + } } if (parallelUploadBoundariesCount != null) { diff --git a/lib/upload.ts b/lib/upload.ts index 2c6795594..187ce979c 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -200,6 +200,7 @@ export class BaseUpload { parallelUploads: this.options.parallelUploads, protocol: this.options.protocol, retryDelays: this.options.retryDelays, + uploadDataDuringCreation: this.options.uploadDataDuringCreation, uploadLengthDeferred: this._uploadLengthDeferred, }) if (!startValidation.ok) { @@ -269,6 +270,7 @@ export class BaseUpload { } const { parts, totalSize } = parallelUploadPartsPlan + let totalAccepted = 0 let totalProgress = 0 this._parallelUploads = [] @@ -278,6 +280,7 @@ export class BaseUpload { // Generate a promise for each slice that will be resolve if the respective // upload is completed. const uploads = parts.map(async (part, index) => { + let lastPartAccepted = 0 let lastPartProgress = 0 // @ts-expect-error We know that `_source` is not null here. @@ -308,6 +311,17 @@ export class BaseUpload { totalProgress, }) }, + onChunkComplete: (chunkSize: number, bytesAccepted: number) => { + totalAccepted = totalAccepted - lastPartAccepted + bytesAccepted + lastPartAccepted = bytesAccepted + this._emitChunkComplete({ + bytesAccepted: totalAccepted, + bytesTotal: totalSize, + chunkSize, + hasHook: typeof this.options.onChunkComplete === 'function', + phase: 'afterChunkAccepted', + }) + }, // Wait until every partial upload has an upload URL, so we can add // them to the URL storage. onUploadUrlAvailable: async () => { diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index c066ca0e9..13a9f8c14 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -437,7 +437,7 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: ['creationWithUpload'], + scenarioIds: ['creationWithUpload', 'creationWithUploadPartialChunk'], status: 'covered-by-generated-scenario', }, description: 'Send the first bytes on the creation request when the server/client support it.', @@ -454,7 +454,7 @@ export const tusClientFeatures = [ summary: 'Interpret the creation response as an accepted offset.', }, ], - operationIds: ['createTusUpload'], + operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['upload-during-creation', 'emit-progress'], }, { @@ -480,6 +480,33 @@ export const tusClientFeatures = [ operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['send-upload-body-headers'], }, + { + conformance: { + scenarioIds: ['customRequestHeaders'], + status: 'covered-by-generated-scenario', + }, + description: 'Apply user-provided request headers to every upload request.', + featureId: 'customRequestHeaders', + flow: [ + { + kind: 'primitive', + primitive: 'apply-custom-request-headers', + summary: 'Merge user-provided headers after protocol headers are prepared.', + }, + { + kind: 'operation', + operationId: 'createTusUpload', + summary: 'Create uploads with the configured custom headers.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Upload bytes with the configured custom headers.', + }, + ], + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['apply-custom-request-headers'], + }, { conformance: { scenarioIds: ['overridePatchMethod'], @@ -509,10 +536,11 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: ['parallelUploadConcat'], + scenarioIds: ['parallelUploadConcat', 'parallelUploadAbortCleanup'], status: 'covered-by-generated-scenario', }, - description: 'Split one input into partial uploads and concatenate their upload URLs.', + description: + 'Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.', featureId: 'parallelUploadConcat', flow: [ { @@ -533,9 +561,11 @@ export const tusClientFeatures = [ ], operationIds: ['createTusUpload', 'patchTusUpload'], primitives: [ + 'abort-current-request', 'concatenate-partial-uploads', 'emit-progress', 'split-parallel-upload-boundaries', + 'terminate-upload', ], }, { @@ -615,7 +645,7 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: ['abortUpload'], + scenarioIds: ['abortUpload', 'abortUploadAfterStoredUrl'], status: 'covered-by-generated-scenario', }, description: 'Abort the active request, pending retry timer, and any partial uploads.', @@ -627,8 +657,8 @@ export const tusClientFeatures = [ summary: 'Cancel in-flight transport work without emitting user callbacks after abort.', }, ], - operationIds: [], - primitives: ['abort-current-request'], + operationIds: ['terminateTusUpload'], + primitives: ['abort-current-request', 'terminate-upload'], }, { conformance: { @@ -810,6 +840,7 @@ export const tusClientFeatures = [ 'startValidationParallelUploadsWithUploadUrl', 'startValidationParallelUploadsWithUploadSize', 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelUploadsWithUploadDataDuringCreation', 'startValidationParallelBoundariesWithoutParallelUploads', 'startValidationParallelBoundariesLengthMismatch', ], @@ -857,36 +888,44 @@ export const tusClientConformanceScenarios = [ { fingerprint: 'contract-single-fingerprint', kind: 'fingerprint', + key: 'fingerprint:contract-single-fingerprint', }, { kind: 'upload-url-available', + key: 'upload-url-available', }, { fingerprint: 'contract-single-fingerprint', kind: 'url-storage-add', uploadUrl: 'https://tus.io/uploads/generated-contract', + key: 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', }, { bytesSent: 0, bytesTotal: 11, kind: 'progress', + key: 'progress:0:11', }, { bytesSent: 11, bytesTotal: 11, kind: 'progress', + key: 'progress:11:11', }, { bytesAccepted: 11, bytesTotal: 11, chunkSize: 11, kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'singleUploadLifecycle', @@ -950,20 +989,25 @@ export const tusClientConformanceScenarios = [ bytesSent: 0, bytesTotal: 11, kind: 'progress', + key: 'progress:0:11', }, { bytesSent: 11, bytesTotal: 11, kind: 'progress', + key: 'progress:11:11', }, { kind: 'upload-url-available', + key: 'upload-url-available', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'creationWithUpload', @@ -998,6 +1042,141 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'creationWithUpload', }, + { + behavior: 'creation-with-upload-partial-chunk', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + }, + events: [ + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + key: 'progress:0:11', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesSent: 10, + bytesTotal: 11, + kind: 'progress', + key: 'progress:10:11', + }, + { + bytesAccepted: 10, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:10:11', + }, + { + bytesSent: 10, + bytesTotal: 11, + kind: 'progress', + key: 'progress:10:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 1, + kind: 'chunk-complete', + key: 'chunk-complete:1:11:11', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], + featureId: 'creationWithUpload', + input: { + chunkSize: 5, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + uploadDataDuringCreation: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['upload-during-creation', 'emit-progress'], + requests: [ + { + bodySize: 5, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/creation-with-upload-partial-contract', + 'Upload-Offset': '5', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 5, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '10', + }, + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + url: 'upload', + }, + { + bodySize: 1, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + url: 'upload', + }, + ], + scenarioId: 'creationWithUploadPartialChunk', + }, { behavior: 'creation-with-upload', completion: { @@ -1009,20 +1188,25 @@ export const tusClientConformanceScenarios = [ bytesSent: 0, bytesTotal: 11, kind: 'progress', + key: 'progress:0:11', }, { bytesSent: 11, bytesTotal: 11, kind: 'progress', + key: 'progress:11:11', }, { kind: 'upload-url-available', + key: 'upload-url-available', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'protocolVersionSelection', @@ -1072,28 +1256,34 @@ export const tusClientConformanceScenarios = [ events: [ { kind: 'upload-url-available', + key: 'upload-url-available', }, { bytesSent: 5, bytesTotal: 11, kind: 'progress', + key: 'progress:5:11', }, { bytesSent: 11, bytesTotal: 11, kind: 'progress', + key: 'progress:11:11', }, { bytesAccepted: 11, bytesTotal: 11, chunkSize: 6, kind: 'chunk-complete', + key: 'chunk-complete:6:11:11', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'protocolVersionSelection', @@ -1288,6 +1478,28 @@ export const tusClientConformanceScenarios = [ requests: [], scenarioId: 'startValidationParallelUploadsWithDeferredLength', }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: + 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + reason: 'parallelUploadsWithUploadDataDuringCreation', + }, + events: [], + featureId: 'startOptionValidation', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + parallelUploads: 2, + uploadDataDuringCreation: true, + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', + }, { behavior: 'start-option-validation', completion: { @@ -1474,6 +1686,64 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'uploadBodyHeaders', }, + { + behavior: 'custom-request-headers', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/custom-headers-contract', + }, + events: [], + featureId: 'customRequestHeaders', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + headers: { + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['apply-custom-request-headers'], + requests: [ + { + headers: { + 'Upload-Length': '11', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/custom-headers-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 11, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '11', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'customRequestHeaders', + }, { behavior: 'resume-from-previous-upload', completion: { @@ -1484,44 +1754,54 @@ export const tusClientConformanceScenarios = [ { fingerprint: 'contract-resume-fingerprint', kind: 'fingerprint', + key: 'fingerprint:contract-resume-fingerprint', }, { count: 1, fingerprint: 'contract-resume-fingerprint', kind: 'url-storage-find', + key: 'url-storage-find:contract-resume-fingerprint:1', }, { fingerprint: 'contract-resume-fingerprint', kind: 'fingerprint', + key: 'fingerprint:contract-resume-fingerprint', }, { kind: 'upload-url-available', + key: 'upload-url-available', }, { bytesSent: 5, bytesTotal: 11, kind: 'progress', + key: 'progress:5:11', }, { bytesSent: 11, bytesTotal: 11, kind: 'progress', + key: 'progress:11:11', }, { bytesAccepted: 11, bytesTotal: 11, chunkSize: 6, kind: 'chunk-complete', + key: 'chunk-complete:6:11:11', }, { kind: 'url-storage-remove', urlStorageKey: 'tus::contract-resume-fingerprint::1337', + key: 'url-storage-remove:tus::contract-resume-fingerprint::1337', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'resumeUpload', @@ -1576,28 +1856,34 @@ export const tusClientConformanceScenarios = [ events: [ { kind: 'upload-url-available', + key: 'upload-url-available', }, { bytesSent: 0, bytesTotal: 11, kind: 'progress', + key: 'progress:0:11', }, { bytesSent: 11, bytesTotal: 11, kind: 'progress', + key: 'progress:11:11', }, { bytesAccepted: 11, bytesTotal: 11, chunkSize: 11, kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'relativeLocationResolution', @@ -1653,12 +1939,15 @@ export const tusClientConformanceScenarios = [ inputKind: 'array-buffer', kind: 'source-open', size: 11, + key: 'source-open:array-buffer:11', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'inputSources', @@ -1714,12 +2003,15 @@ export const tusClientConformanceScenarios = [ inputKind: 'array-buffer-view', kind: 'source-open', size: 11, + key: 'source-open:array-buffer-view:11', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'inputSources', @@ -1775,12 +2067,15 @@ export const tusClientConformanceScenarios = [ inputKind: 'web-readable-stream', kind: 'source-open', size: null, + key: 'source-open:web-readable-stream:null', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'inputSources', @@ -1840,12 +2135,15 @@ export const tusClientConformanceScenarios = [ inputKind: 'node-readable-stream', kind: 'source-open', size: null, + key: 'source-open:node-readable-stream:null', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'inputSources', @@ -1906,12 +2204,15 @@ export const tusClientConformanceScenarios = [ inputKind: 'node-path-reference', kind: 'source-open', size: 11, + key: 'source-open:node-path-reference:11', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'inputSources', @@ -1963,7 +2264,39 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/deferred-contract', }, - events: [], + events: [ + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + key: 'progress:0:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], featureId: 'deferredLengthUpload', input: { chunkSize: 100, @@ -2067,7 +2400,34 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/parallel-final', }, - events: [], + events: [ + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesAccepted: 5, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:5:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 6, + kind: 'chunk-complete', + key: 'chunk-complete:6:11:11', + }, + ], featureId: 'parallelUploadConcat', input: { content: 'hello world', @@ -2169,6 +2529,138 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'parallelUploadConcat', }, + { + behavior: 'parallel-upload-abort-cleanup', + completion: { + kind: 'aborted', + }, + events: [ + { + kind: 'request-abort', + requestIndex: 3, + key: 'request-abort:3', + }, + ], + featureId: 'parallelUploadConcat', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + fingerprint: 'contract-parallel-cleanup-fingerprint', + headers: { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + kind: 'blob', + metadataForPartialUploads: { + test: 'world', + }, + overridePatchMethod: true, + parallelUploads: 2, + terminateUploadOnAbort: true, + }, + operationIds: [ + 'createTusUpload', + 'createTusUpload', + 'patchTusUpload', + 'patchTusUpload', + 'terminateTusUpload', + 'terminateTusUpload', + ], + primitives: ['abort-current-request', 'terminate-upload', 'concatenate-partial-uploads'], + requests: [ + { + headers: { + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'Upload-Metadata': 'test d29ybGQ=', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/parallel-cleanup-part-1', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + headers: { + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'Upload-Metadata': 'test d29ybGQ=', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/parallel-cleanup-part-2', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 5, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + method: 'POST', + operationId: 'patchTusUpload', + response: { + statusCode: 500, + }, + uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', + url: 'upload', + }, + { + abort: true, + bodySize: 6, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + method: 'POST', + operationId: 'patchTusUpload', + uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', + url: 'upload', + }, + { + headers: { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + operationId: 'terminateTusUpload', + response: { + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', + url: 'upload', + }, + { + headers: { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + operationId: 'terminateTusUpload', + response: { + statusCode: 204, + }, + uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', + url: 'upload', + }, + ], + scenarioId: 'parallelUploadAbortCleanup', + }, { behavior: 'retry-patch-after-offset-recovery', completion: { @@ -2180,19 +2672,23 @@ export const tusClientConformanceScenarios = [ decision: true, kind: 'should-retry', retryAttempt: 0, + key: 'should-retry:0:true', }, { delay: 0, kind: 'retry-schedule', + key: 'retry-schedule:0', }, { decision: true, kind: 'should-retry', retryAttempt: 0, + key: 'should-retry:0:true', }, { delay: 0, kind: 'retry-schedule', + key: 'retry-schedule:0', }, ], featureId: 'retryOffsetRecovery', @@ -2299,16 +2795,20 @@ export const tusClientConformanceScenarios = [ { kind: 'before-request', requestIndex: 0, + key: 'before-request:0', }, { kind: 'after-response', requestIndex: 0, + key: 'after-response:0', }, { kind: 'success', + key: 'success', }, { kind: 'source-close', + key: 'source-close', }, ], featureId: 'requestLifecycleHooks', @@ -2344,6 +2844,7 @@ export const tusClientConformanceScenarios = [ { kind: 'request-abort', requestIndex: 0, + key: 'request-abort:0', }, ], featureId: 'abortUpload', @@ -2369,6 +2870,79 @@ export const tusClientConformanceScenarios = [ ], scenarioId: 'abortUpload', }, + { + behavior: 'abort-upload-after-stored-url', + completion: { + kind: 'aborted', + uploadUrl: 'https://tus.io/uploads/abort-terminate-contract', + }, + events: [ + { + kind: 'request-abort', + requestIndex: 1, + key: 'request-abort:1', + }, + ], + featureId: 'abortUpload', + input: { + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + fingerprint: 'contract-abort-terminate-fingerprint', + headers: { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + overridePatchMethod: true, + terminateUploadOnAbort: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload', 'terminateTusUpload'], + primitives: ['abort-current-request', 'terminate-upload'], + requests: [ + { + headers: { + 'Upload-Length': '11', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/abort-terminate-contract', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + abort: true, + bodySize: 11, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + method: 'POST', + operationId: 'patchTusUpload', + url: 'upload', + }, + { + headers: { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + operationId: 'terminateTusUpload', + response: { + statusCode: 204, + }, + url: 'upload', + }, + ], + scenarioId: 'abortUploadAfterStoredUrl', + }, { behavior: 'terminate-with-retry', completion: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3aab5436a..456f4dbe3 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -439,11 +439,13 @@ async function abortScenarioRequest(req, scenario, request, requestIndex, observ return originalAbort() } - await upload.abort(false) + const abortPromise = upload.abort(Boolean(scenario.input.terminateUploadOnAbort)) await wait(0) expect(req.method).toBe(request.method ?? operation.method) expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) + + return abortPromise } async function startScenarioUpload(scenario, testStack) { @@ -682,6 +684,7 @@ async function runGeneratedConformanceScenario(scenario) { terminatePromise, upload, } = await startScenarioUpload(scenario, testStack) + const abortPromises = [] try { for (const [requestIndex, request] of scenario.requests.entries()) { @@ -689,7 +692,9 @@ async function runGeneratedConformanceScenario(scenario) { expectScenarioRequest(req, scenario, request) if (request.abort) { - await abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload) + abortPromises.push( + abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload), + ) } else if (request.error) { req.responseError(new Error(request.error.message)) } else if (!request.response) { @@ -700,6 +705,7 @@ async function runGeneratedConformanceScenario(scenario) { } if (scenario.completion.kind === 'aborted') { + await Promise.all(abortPromises) expect(onSuccess).not.toHaveBeenCalled() expect(onError).not.toHaveBeenCalled() expectScenarioEvents(scenario, observedEvents) @@ -748,6 +754,7 @@ describe('generated TUS protocol contract', () => { expect(tusClientConformanceScenarios.map((scenario) => scenario.scenarioId)).toEqual([ 'singleUploadLifecycle', 'creationWithUpload', + 'creationWithUploadPartialChunk', 'ietfDraft05CreationWithUpload', 'ietfDraft03ResumeWithoutKnownLength', 'startValidationMissingInput', @@ -757,11 +764,13 @@ describe('generated TUS protocol contract', () => { 'startValidationParallelUploadsWithUploadUrl', 'startValidationParallelUploadsWithUploadSize', 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelUploadsWithUploadDataDuringCreation', 'startValidationParallelBoundariesWithoutParallelUploads', 'startValidationParallelBoundariesLengthMismatch', 'detailedCreateResponseError', 'detailedCreateRequestError', 'uploadBodyHeaders', + 'customRequestHeaders', 'resumeFromPreviousUpload', 'relativeLocationResolution', 'arrayBufferInput', @@ -772,9 +781,11 @@ describe('generated TUS protocol contract', () => { 'deferredLengthUpload', 'overridePatchMethod', 'parallelUploadConcat', + 'parallelUploadAbortCleanup', 'retryPatchAfterOffsetRecovery', 'requestLifecycleHooks', 'abortUpload', + 'abortUploadAfterStoredUrl', 'terminateWithRetry', ]) }) From 3df1f2eb65b1d45c18032a1d0ac292cc7dd7c6d9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 22:56:26 +0200 Subject: [PATCH 082/155] Tighten generated runtime event keys --- test/spec/test-generated-protocol-contract.js | 146 +++++++++--------- 1 file changed, 69 insertions(+), 77 deletions(-) diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 456f4dbe3..02c2da4d2 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -263,118 +263,110 @@ function makeEventRecordingFileReader(fileReader, scenario, observedEvents) { } } -function requestExpectationForEvent(scenario, event) { - const request = scenario.requests[event.requestIndex] - if (!request) { - throw new Error( - `Generated scenario ${scenario.scenarioId} event points at missing request index ${event.requestIndex}`, - ) - } - - return request +function formatEventValue(value) { + return value == null ? 'null' : String(value) } -function eventMatchesExpectation(scenario, actual, expected) { - if (actual.kind !== expected.kind) { - return false +function observedEventKey(event) { + if (event.kind === 'progress') { + return `progress:${event.bytesSent}:${formatEventValue(event.bytesTotal)}` } - if (expected.kind === 'progress') { - return actual.bytesSent === expected.bytesSent && actual.bytesTotal === expected.bytesTotal + if (event.kind === 'chunk-complete') { + return `chunk-complete:${event.chunkSize}:${event.bytesAccepted}:${formatEventValue( + event.bytesTotal, + )}` } - if (expected.kind === 'chunk-complete') { - return ( - actual.bytesAccepted === expected.bytesAccepted && - actual.bytesTotal === expected.bytesTotal && - actual.chunkSize === expected.chunkSize - ) + if (event.kind === 'before-request') { + return `before-request:${event.requestIndex}` } - if (expected.kind === 'before-request' || expected.kind === 'after-response') { - const request = requestExpectationForEvent(scenario, expected) - const operation = getProtocolOperation(request.operationId) - if ( - actual.requestIndex !== expected.requestIndex || - actual.method !== (request.method ?? operation.method) || - actual.url !== expectedUrlForScenarioRequest(scenario, request) - ) { - return false - } - - if (expected.kind === 'after-response') { - if (!request.response) { - throw new Error( - `Generated scenario ${scenario.scenarioId} after-response event points at request ${expected.requestIndex} without a response`, - ) - } - - return actual.statusCode === request.response.statusCode - } + if (event.kind === 'after-response') { + return `after-response:${event.requestIndex}` + } - return true + if (event.kind === 'request-abort') { + return `request-abort:${event.requestIndex}` } - if (expected.kind === 'request-abort') { - const request = requestExpectationForEvent(scenario, expected) - const operation = getProtocolOperation(request.operationId) - return ( - actual.requestIndex === expected.requestIndex && - actual.method === (request.method ?? operation.method) && - actual.url === expectedUrlForScenarioRequest(scenario, request) - ) + if (event.kind === 'source-open') { + return `source-open:${event.inputKind}:${formatEventValue(event.size)}` } - if (expected.kind === 'source-open') { - return actual.inputKind === expected.inputKind && actual.size === expected.size + if (event.kind === 'fingerprint') { + return `fingerprint:${formatEventValue(event.fingerprint)}` } - if (expected.kind === 'fingerprint') { - return actual.fingerprint === expected.fingerprint + if (event.kind === 'should-retry') { + return `should-retry:${event.retryAttempt}:${event.decision}` } - if (expected.kind === 'should-retry') { - return actual.decision === expected.decision && actual.retryAttempt === expected.retryAttempt + if (event.kind === 'retry-schedule') { + return `retry-schedule:${event.delay}` } - if (expected.kind === 'retry-schedule') { - return actual.delay === expected.delay + if (event.kind === 'url-storage-add') { + return `url-storage-add:${event.fingerprint}:${event.uploadUrl}` } - if (expected.kind === 'url-storage-add') { - return actual.fingerprint === expected.fingerprint && actual.uploadUrl === expected.uploadUrl + if (event.kind === 'url-storage-find') { + return `url-storage-find:${event.fingerprint}:${event.count}` } - if (expected.kind === 'url-storage-find') { - return actual.count === expected.count && actual.fingerprint === expected.fingerprint + if (event.kind === 'url-storage-remove') { + return `url-storage-remove:${event.urlStorageKey}` } - if (expected.kind === 'url-storage-remove') { - return actual.urlStorageKey === expected.urlStorageKey + return event.kind +} + +function expectedEventKey(scenario, event) { + if (event.key == null) { + throw new Error( + `Generated scenario ${scenario.scenarioId} has an event without a generated key: ${JSON.stringify( + event, + )}`, + ) } - return true + return event.key } function expectScenarioEvents(scenario, observedEvents) { - let searchStart = 0 + const expectedEventKeys = scenario.events.map((event) => expectedEventKey(scenario, event)) + const observedEventKeys = observedEvents.map(observedEventKey) - for (const expectedEvent of scenario.events) { - const matchedIndex = observedEvents.findIndex( - (actualEvent, index) => - index >= searchStart && eventMatchesExpectation(scenario, actualEvent, expectedEvent), - ) + if ( + scenario.events.some((event) => event.kind === 'progress' || event.kind === 'chunk-complete') + ) { + let searchStart = 0 - expect(matchedIndex) - .withContext( - `Expected generated scenario ${scenario.scenarioId} to emit ${JSON.stringify( - expectedEvent, - )} after event index ${searchStart - 1}; observed ${JSON.stringify(observedEvents)}`, + for (const expectedEventKey of expectedEventKeys) { + const matchedIndex = observedEventKeys.findIndex( + (actualEventKey, index) => index >= searchStart && actualEventKey === expectedEventKey, ) - .not.toBe(-1) - searchStart = matchedIndex + 1 + expect(matchedIndex) + .withContext( + `Expected generated scenario ${scenario.scenarioId} to emit ${expectedEventKey} after event index ${ + searchStart - 1 + }; observed keys ${JSON.stringify(observedEventKeys)}`, + ) + .not.toBe(-1) + + searchStart = matchedIndex + 1 + } + return } + + expect(observedEventKeys) + .withContext( + `Expected generated scenario ${scenario.scenarioId} runtime event keys to match exactly; observed events ${JSON.stringify( + observedEvents, + )}`, + ) + .toEqual(expectedEventKeys) } function expectedUrlForScenarioRequest(scenario, request) { From d50a57603ef25f8fe0f250d4c206fb76b7ecc6ff Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 23:23:32 +0200 Subject: [PATCH 083/155] Use generated TUS event policy --- test/spec/generated-protocol-contract.js | 45 +++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 26 ++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 13a9f8c14..2c122dd64 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -928,6 +928,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'singleUploadLifecycle', input: { content: 'hello world', @@ -1010,6 +1015,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'creationWithUpload', input: { content: 'hello world', @@ -1112,6 +1122,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'creationWithUpload', input: { chunkSize: 5, @@ -1209,6 +1224,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'protocolVersionSelection', input: { content: 'hello world', @@ -1286,6 +1306,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'protocolVersionSelection', input: { chunkSize: 6, @@ -1804,6 +1829,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'resumeUpload', input: { content: 'hello world', @@ -1886,6 +1916,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'relativeLocationResolution', input: { content: 'hello world', @@ -2297,6 +2332,11 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'deferredLengthUpload', input: { chunkSize: 100, @@ -2428,6 +2468,11 @@ export const tusClientConformanceScenarios = [ key: 'chunk-complete:6:11:11', }, ], + eventPolicy: { + matching: 'ordered-subsequence', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, featureId: 'parallelUploadConcat', input: { content: 'hello world', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 02c2da4d2..6a12c9c00 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -336,10 +336,9 @@ function expectedEventKey(scenario, event) { function expectScenarioEvents(scenario, observedEvents) { const expectedEventKeys = scenario.events.map((event) => expectedEventKey(scenario, event)) const observedEventKeys = observedEvents.map(observedEventKey) + const eventPolicy = scenario.eventPolicy ?? { matching: 'exact' } - if ( - scenario.events.some((event) => event.kind === 'progress' || event.kind === 'chunk-complete') - ) { + if (eventPolicy.matching === 'ordered-subsequence') { let searchStart = 0 for (const expectedEventKey of expectedEventKeys) { @@ -360,13 +359,20 @@ function expectScenarioEvents(scenario, observedEvents) { return } - expect(observedEventKeys) - .withContext( - `Expected generated scenario ${scenario.scenarioId} runtime event keys to match exactly; observed events ${JSON.stringify( - observedEvents, - )}`, - ) - .toEqual(expectedEventKeys) + if (eventPolicy.matching === 'exact') { + expect(observedEventKeys) + .withContext( + `Expected generated scenario ${scenario.scenarioId} runtime event keys to match exactly; observed events ${JSON.stringify( + observedEvents, + )}`, + ) + .toEqual(expectedEventKeys) + return + } + + throw new Error( + `Unsupported generated event policy for ${scenario.scenarioId}: ${JSON.stringify(eventPolicy)}`, + ) } function expectedUrlForScenarioRequest(scenario, request) { From ee7362ec3c7dafcc54bafe0840afa1780e53e5a7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 07:57:42 +0200 Subject: [PATCH 084/155] Tighten generated TUS event matching --- test/spec/generated-protocol-contract.js | 25 +++++--- test/spec/test-generated-protocol-contract.js | 61 +++++++++++++------ 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 2c122dd64..0efe90880 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -929,7 +929,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -1016,7 +1016,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -1075,6 +1075,13 @@ export const tusClientConformanceScenarios = [ kind: 'upload-url-available', key: 'upload-url-available', }, + { + bytesAccepted: 5, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:5:11', + }, { bytesSent: 5, bytesTotal: 11, @@ -1123,7 +1130,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -1225,7 +1232,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -1307,7 +1314,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -1830,7 +1837,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -1917,7 +1924,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2333,7 +2340,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2469,7 +2476,7 @@ export const tusClientConformanceScenarios = [ }, ], eventPolicy: { - matching: 'ordered-subsequence', + matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 6a12c9c00..3975bf8d4 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -333,29 +333,54 @@ function expectedEventKey(scenario, event) { return event.key } -function expectScenarioEvents(scenario, observedEvents) { - const expectedEventKeys = scenario.events.map((event) => expectedEventKey(scenario, event)) - const observedEventKeys = observedEvents.map(observedEventKey) - const eventPolicy = scenario.eventPolicy ?? { matching: 'exact' } +function isProgressEventKey(eventKey) { + return eventKey.startsWith('progress:') +} - if (eventPolicy.matching === 'ordered-subsequence') { - let searchStart = 0 +function expectScenarioEventsExactExceptExtraProgress( + scenario, + observedEvents, + observedEventKeys, + expectedEventKeys, +) { + let expectedIndex = 0 + + for (const observedEventKey of observedEventKeys) { + if (observedEventKey === expectedEventKeys[expectedIndex]) { + expectedIndex += 1 + continue + } - for (const expectedEventKey of expectedEventKeys) { - const matchedIndex = observedEventKeys.findIndex( - (actualEventKey, index) => index >= searchStart && actualEventKey === expectedEventKey, + expect(isProgressEventKey(observedEventKey)) + .withContext( + `Expected generated scenario ${scenario.scenarioId} to only emit extra progress samples; observed events ${JSON.stringify( + observedEvents, + )}; expected keys ${JSON.stringify(expectedEventKeys)}`, ) + .toBe(true) + } - expect(matchedIndex) - .withContext( - `Expected generated scenario ${scenario.scenarioId} to emit ${expectedEventKey} after event index ${ - searchStart - 1 - }; observed keys ${JSON.stringify(observedEventKeys)}`, - ) - .not.toBe(-1) + expect(expectedIndex) + .withContext( + `Expected generated scenario ${scenario.scenarioId} to emit every non-extra event; observed keys ${JSON.stringify( + observedEventKeys, + )}; expected keys ${JSON.stringify(expectedEventKeys)}`, + ) + .toBe(expectedEventKeys.length) +} - searchStart = matchedIndex + 1 - } +function expectScenarioEvents(scenario, observedEvents) { + const expectedEventKeys = scenario.events.map((event) => expectedEventKey(scenario, event)) + const observedEventKeys = observedEvents.map(observedEventKey) + const eventPolicy = scenario.eventPolicy ?? { matching: 'exact' } + + if (eventPolicy.matching === 'exact-except-extra-progress') { + expectScenarioEventsExactExceptExtraProgress( + scenario, + observedEvents, + observedEventKeys, + expectedEventKeys, + ) return } From b88fdecb025504962c224a5b6e83ac5bfc9dc4db Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:25:16 +0200 Subject: [PATCH 085/155] Update generated TUS retry events --- test/spec/generated-protocol-contract.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 0efe90880..eb087b589 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -3001,7 +3001,19 @@ export const tusClientConformanceScenarios = [ kind: 'terminated', uploadUrl: 'https://tus.io/uploads/terminate-contract', }, - events: [], + events: [ + { + decision: true, + kind: 'should-retry', + retryAttempt: 0, + key: 'should-retry:0:true', + }, + { + delay: 0, + kind: 'retry-schedule', + key: 'retry-schedule:0', + }, + ], featureId: 'terminateUpload', input: { chunkSize: 5, From 14ec595395b316ba0e00b7dfd8cdaebf5adbabfb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:58:03 +0200 Subject: [PATCH 086/155] Use generated TUS proof profiles --- test/spec/generated-protocol-contract.js | 55 +++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 51 +++++------------ 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index eb087b589..b10951a06 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -3074,6 +3074,61 @@ export const tusClientConformanceScenarios = [ }, ] +export const tusClientScenarioProofCases = [ + { + behavior: 'single-upload-lifecycle', + completionKind: 'success', + featureId: 'singleUploadLifecycle', + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + profile: 'urlStorageCreateFlow', + scenarioId: 'singleUploadLifecycle', + }, + { + behavior: 'custom-request-headers', + completionKind: 'success', + featureId: 'customRequestHeaders', + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['apply-custom-request-headers'], + profile: 'customRequestHeaders', + scenarioId: 'customRequestHeaders', + }, + { + behavior: 'override-patch-method', + completionKind: 'success', + featureId: 'overridePatchMethod', + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['override-patch-method'], + profile: 'overridePatchMethod', + scenarioId: 'overridePatchMethod', + }, + { + behavior: 'node-path-input', + completionKind: 'success', + featureId: 'inputSources', + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-node-file'], + profile: 'nodePathFileUpload', + scenarioId: 'nodePathInput', + }, + { + behavior: 'resume-from-previous-upload', + completionKind: 'success', + featureId: 'resumeUpload', + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], + profile: 'resumeFromPreviousUpload', + scenarioId: 'resumeFromPreviousUpload', + }, +] + export const tusClientUrlStorageConformanceScenarios = [ { actions: [ diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3975bf8d4..d273fd40b 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -2,6 +2,7 @@ import { defaultOptions, Upload } from 'tus-js-client' import { tusClientConformanceScenarios, tusClientFeatures, + tusClientScenarioProofCases, tusProtocolOperations, tusWireVersions, } from './generated-protocol-contract.js' @@ -773,43 +774,17 @@ describe('generated TUS protocol contract', () => { }) } - it('covers the expected first wave of generated conformance scenarios', () => { - expect(tusClientConformanceScenarios.map((scenario) => scenario.scenarioId)).toEqual([ - 'singleUploadLifecycle', - 'creationWithUpload', - 'creationWithUploadPartialChunk', - 'ietfDraft05CreationWithUpload', - 'ietfDraft03ResumeWithoutKnownLength', - 'startValidationMissingInput', - 'startValidationMissingEndpointOrUploadUrl', - 'startValidationUnsupportedProtocol', - 'startValidationRetryDelaysNotArray', - 'startValidationParallelUploadsWithUploadUrl', - 'startValidationParallelUploadsWithUploadSize', - 'startValidationParallelUploadsWithDeferredLength', - 'startValidationParallelUploadsWithUploadDataDuringCreation', - 'startValidationParallelBoundariesWithoutParallelUploads', - 'startValidationParallelBoundariesLengthMismatch', - 'detailedCreateResponseError', - 'detailedCreateRequestError', - 'uploadBodyHeaders', - 'customRequestHeaders', - 'resumeFromPreviousUpload', - 'relativeLocationResolution', - 'arrayBufferInput', - 'arrayBufferViewInput', - 'webReadableStreamInput', - 'nodeReadableStreamInput', - 'nodePathInput', - 'deferredLengthUpload', - 'overridePatchMethod', - 'parallelUploadConcat', - 'parallelUploadAbortCleanup', - 'retryPatchAfterOffsetRecovery', - 'requestLifecycleHooks', - 'abortUpload', - 'abortUploadAfterStoredUrl', - 'terminateWithRetry', - ]) + it('preserves the generated proof-profile scenarios', () => { + for (const proofCase of tusClientScenarioProofCases) { + const scenario = getClientConformanceScenario(proofCase.scenarioId) + const feature = getClientFeature(proofCase.featureId) + + expect(scenario.behavior).toBe(proofCase.behavior) + expect(scenario.completion.kind).toBe(proofCase.completionKind) + expect(scenario.featureId).toBe(proofCase.featureId) + expect(feature.conformance.scenarioIds).toContain(scenario.scenarioId) + expect(scenario.operationIds).toEqual(proofCase.operationIds) + expect(scenario.primitives).toEqual(proofCase.primitives) + } }) }) From e106fd932328175669c6648c199b6a88439d051a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 10:12:31 +0200 Subject: [PATCH 087/155] Use generated TUS execution hints --- test/spec/generated-protocol-contract.js | 17 ++++++++ test/spec/test-generated-protocol-contract.js | 39 ++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b10951a06..3c2969592 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1841,6 +1841,15 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + execution: { + beforeStart: [ + { + expectedPreviousUploadCount: 1, + kind: 'resume-from-previous-upload', + selectedPreviousUploadIndex: 0, + }, + ], + }, featureId: 'resumeUpload', input: { content: 'hello world', @@ -3014,6 +3023,14 @@ export const tusClientConformanceScenarios = [ key: 'retry-schedule:0', }, ], + execution: { + onChunkComplete: [ + { + kind: 'abort-upload', + terminateUpload: true, + }, + ], + }, featureId: 'terminateUpload', input: { chunkSize: 5, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index d273fd40b..20025bc3c 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -235,6 +235,10 @@ function scenarioWantsEvent(scenario, kind) { return scenario.events.some((event) => event.kind === kind) } +function scenarioExecutionActions(scenario, phase) { + return scenario.execution?.[phase] ?? [] +} + function makeEventRecordingFileReader(fileReader, scenario, observedEvents) { return { async openFile(input, chunkSize) { @@ -646,25 +650,40 @@ async function startScenarioUpload(scenario, testStack) { options.urlStorage = makeEventRecordingUrlStorage(scenario.input.storedUpload, observedEvents) } - if (scenario.behavior === 'terminate-with-retry') { + const onChunkCompleteActions = scenarioExecutionActions(scenario, 'onChunkComplete') + if (scenarioWantsEvent(scenario, 'chunk-complete') || onChunkCompleteActions.length > 0) { options.onChunkComplete = (chunkSize, bytesAccepted, bytesTotal) => { if (scenarioWantsEvent(scenario, 'chunk-complete')) { observedEvents.push({ bytesAccepted, bytesTotal, chunkSize, kind: 'chunk-complete' }) } - terminatePromise = upload.abort(true) - } - } else if (scenarioWantsEvent(scenario, 'chunk-complete')) { - options.onChunkComplete = (chunkSize, bytesAccepted, bytesTotal) => { - observedEvents.push({ bytesAccepted, bytesTotal, chunkSize, kind: 'chunk-complete' }) + for (const action of onChunkCompleteActions) { + if (action.kind === 'abort-upload') { + terminatePromise = upload.abort(action.terminateUpload) + continue + } + + throw new Error( + `Unsupported generated onChunkComplete action for ${scenario.scenarioId}: ${action.kind}`, + ) + } } } upload = new Upload(await createScenarioInput(scenario.input), options) - if (scenario.behavior === 'resume-from-previous-upload') { - const previousUploads = await upload.findPreviousUploads() - expect(previousUploads.length).toBe(1) - upload.resumeFromPreviousUpload(previousUploads[0]) + for (const action of scenarioExecutionActions(scenario, 'beforeStart')) { + if (action.kind === 'resume-from-previous-upload') { + const previousUploads = await upload.findPreviousUploads() + expect(previousUploads.length).toBe(action.expectedPreviousUploadCount) + const previousUpload = previousUploads[action.selectedPreviousUploadIndex] + expect(previousUpload).toBeDefined() + upload.resumeFromPreviousUpload(previousUpload) + continue + } + + throw new Error( + `Unsupported generated beforeStart action for ${scenario.scenarioId}: ${action.kind}`, + ) } upload.start() From 34d81eb7d99a9fd6416171076c41ea34d346b49f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:01:46 +0200 Subject: [PATCH 088/155] Expose TUS request-start cancellation hints --- test/spec/generated-protocol-contract.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 3c2969592..518f79d4b 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2908,6 +2908,14 @@ export const tusClientConformanceScenarios = [ key: 'request-abort:0', }, ], + execution: { + onRequestStart: [ + { + kind: 'cancel-upload', + requestIndex: 0, + }, + ], + }, featureId: 'abortUpload', input: { content: 'hello world', @@ -2944,6 +2952,14 @@ export const tusClientConformanceScenarios = [ key: 'request-abort:1', }, ], + execution: { + onRequestStart: [ + { + kind: 'cancel-upload', + requestIndex: 1, + }, + ], + }, featureId: 'abortUpload', input: { content: 'hello world', From 6e5cacb1e7554672660b812c6d8cbcf113e92488 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:19:12 +0200 Subject: [PATCH 089/155] Expose TUS parallel request gates --- test/spec/generated-protocol-contract.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 518f79d4b..ef540ef97 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2489,6 +2489,17 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + execution: { + serverRequestGates: [ + { + gateId: 'parallel-patches', + heldRequestIndexes: [2, 3], + kind: 'release-after-all-started', + releaseAfterRequestIndexes: [2, 3], + timeoutMs: 2000, + }, + ], + }, featureId: 'parallelUploadConcat', input: { content: 'hello world', @@ -2602,6 +2613,17 @@ export const tusClientConformanceScenarios = [ key: 'request-abort:3', }, ], + execution: { + serverRequestGates: [ + { + gateId: 'parallel-cleanup-patches', + heldRequestIndexes: [2, 3], + kind: 'release-after-all-started', + releaseAfterRequestIndexes: [2, 3], + timeoutMs: 2000, + }, + ], + }, featureId: 'parallelUploadConcat', input: { content: 'hello world', From 836776a6c56db8ab77f783d17807237c0cb23c4a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:37:34 +0200 Subject: [PATCH 090/155] Expose TUS managed upload contract --- test/spec/generated-protocol-contract.js | 196 +++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index ef540ef97..abc6f280d 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -877,6 +877,202 @@ export const tusClientFeatures = [ }, ] +export const tusManagedUpload = { + capabilities: { + cleanup: { + policies: [ + 'remove-owned-source-after-success', + 'remove-owned-source-after-cancel', + 'retain-owned-source-after-permanent-failure', + 'retain-source-after-retryable-failure', + 'remove-managed-state-after-terminal-retention', + ], + }, + failureClassification: { + permanentFailures: [ + 'source-unavailable', + 'unretryable-protocol-error', + 'retry-policy-exhausted', + ], + retryableFailures: ['retryable-protocol-error', 'io-error', 'network-unavailable'], + }, + networkConstraints: { + options: ['any-network', 'unmetered-network'], + }, + retryPolicy: { + controls: [ + 'max-attempts', + 'deadline', + 'progress-sensitive-budget', + 'unbounded-until-permanent-failure', + ], + permanentFailure: 'stop-without-retry', + progressReset: 'reset-budget-after-accepted-offset-advances', + }, + scheduling: { + strategies: ['foreground-task', 'process-lifetime-worker-pool', 'durable-os-scheduler'], + }, + sourceDurability: { + ownedCopyCleanup: 'after-success-or-cancel', + strategies: ['copy-to-owned-storage', 'reference-original-source', 'memory-only'], + }, + stateReporting: { + states: ['pending', 'running', 'succeeded', 'failed'], + terminalRetention: 'session-and-next-launch', + transientRetention: 'until-terminal', + }, + }, + conformance: { + scenarioIds: [ + 'managedUploadDurableRetry', + 'managedUploadPermanentFailure', + 'managedUploadNetworkConstraint', + ], + status: 'needs-generated-scenario', + }, + description: + 'Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.', + featureId: 'managedUpload', + flow: [ + { + kind: 'managed-primitive', + primitive: 'accept-upload-submission', + summary: 'Accept source, metadata, headers, endpoint, and retry/scheduling policy.', + }, + { + kind: 'managed-primitive', + primitive: 'make-source-durable', + summary: 'Keep the source readable according to the selected runtime durability strategy.', + }, + { + kind: 'managed-primitive', + primitive: 'schedule-upload-work', + summary: 'Run upload work according to the runtime scheduler capability.', + }, + { + featureId: 'singleUploadLifecycle', + kind: 'protocol-feature', + summary: 'Use the raw protocol upload lifecycle for each execution attempt.', + }, + { + featureId: 'retryOffsetRecovery', + kind: 'protocol-feature', + summary: 'Use protocol retry and offset recovery before classifying terminal failure.', + }, + { + kind: 'managed-primitive', + primitive: 'publish-upload-state', + summary: 'Expose pending, running, succeeded, and failed state snapshots.', + }, + { + kind: 'managed-primitive', + primitive: 'cleanup-managed-upload', + summary: 'Remove owned sources and terminal state according to cleanup policy.', + }, + ], + layer: 'feature-over-protocol', + primitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + protocolPrimitives: [ + 'store-resume-url', + 'resume-from-previous-upload', + 'recover-offset-after-error', + 'retry-with-backoff', + 'emit-progress', + 'emit-chunk-complete', + 'terminate-upload', + ], + runtimeProfiles: [ + { + networkConstraints: ['any-network', 'unmetered-network'], + runtime: 'android', + scheduler: 'durable-os-scheduler', + sourceDurability: ['copy-to-owned-storage', 'reference-original-source'], + stateBackend: 'platform-key-value-store', + }, + { + networkConstraints: ['any-network', 'unmetered-network'], + runtime: 'ios', + scheduler: 'durable-os-scheduler', + sourceDurability: ['copy-to-owned-storage', 'reference-original-source'], + stateBackend: 'platform-key-value-store', + }, + { + networkConstraints: ['any-network'], + runtime: 'browser', + scheduler: 'foreground-task', + sourceDurability: ['reference-original-source', 'memory-only'], + stateBackend: 'web-storage', + }, + { + networkConstraints: ['any-network'], + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + sourceDurability: ['copy-to-owned-storage', 'reference-original-source'], + stateBackend: 'filesystem', + }, + { + networkConstraints: ['any-network'], + runtime: 'node', + scheduler: 'process-lifetime-worker-pool', + sourceDurability: ['copy-to-owned-storage', 'reference-original-source', 'memory-only'], + stateBackend: 'filesystem', + }, + { + networkConstraints: ['any-network'], + runtime: 'react-native', + scheduler: 'foreground-task', + sourceDurability: ['reference-original-source', 'memory-only'], + stateBackend: 'platform-key-value-store', + }, + ], + scenarios: [ + { + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + scenarioId: 'managedUploadDurableRetry', + summary: + 'Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.', + }, + { + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + ], + scenarioId: 'managedUploadPermanentFailure', + summary: + 'Classify missing sources and unretryable protocol failures as terminal without further retry.', + }, + { + requiredPrimitives: [ + 'accept-upload-submission', + 'schedule-upload-work', + 'publish-upload-state', + ], + scenarioId: 'managedUploadNetworkConstraint', + summary: 'Honor network constraints before starting or resuming upload work.', + }, + ], +} + export const tusClientConformanceScenarios = [ { behavior: 'single-upload-lifecycle', From 73b71490a676ae0168c0682e68113e1b5745c2c2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:52:40 +0200 Subject: [PATCH 091/155] Expose managed upload proof cases --- test/spec/generated-protocol-contract.js | 45 +++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 32 +++++++++++++ 2 files changed, 77 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index abc6f280d..0da0d6dee 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1073,6 +1073,51 @@ export const tusManagedUpload = { ], } +export const tusManagedUploadProofCases = [ + { + featureId: 'managedUpload', + layer: 'feature-over-protocol', + protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadDurableRetry', + }, + { + featureId: 'managedUpload', + layer: 'feature-over-protocol', + protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadPermanentFailure', + }, + { + featureId: 'managedUpload', + layer: 'feature-over-protocol', + protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], + requiredPrimitives: [ + 'accept-upload-submission', + 'schedule-upload-work', + 'publish-upload-state', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadNetworkConstraint', + }, +] + export const tusClientConformanceScenarios = [ { behavior: 'single-upload-lifecycle', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 20025bc3c..703ebbb42 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -3,6 +3,8 @@ import { tusClientConformanceScenarios, tusClientFeatures, tusClientScenarioProofCases, + tusManagedUpload, + tusManagedUploadProofCases, tusProtocolOperations, tusWireVersions, } from './generated-protocol-contract.js' @@ -37,6 +39,17 @@ function getClientConformanceScenario(scenarioId) { return scenario } +function getManagedUploadScenario(scenarioId) { + const scenario = tusManagedUpload.scenarios.find( + (candidate) => candidate.scenarioId === scenarioId, + ) + if (!scenario) { + throw new Error(`Missing generated TUS managed-upload scenario: ${scenarioId}`) + } + + return scenario +} + function getDefaultWireVersion() { const versions = tusWireVersions.filter((candidate) => candidate.default) if (versions.length !== 1) { @@ -806,4 +819,23 @@ describe('generated TUS protocol contract', () => { expect(scenario.primitives).toEqual(proofCase.primitives) } }) + + it('preserves the generated managed-upload proof scenarios', () => { + for (const proofCase of tusManagedUploadProofCases) { + const scenario = getManagedUploadScenario(proofCase.scenarioId) + + expect(tusManagedUpload.featureId).toBe(proofCase.featureId) + expect(tusManagedUpload.layer).toBe(proofCase.layer) + expect(scenario.requiredPrimitives).toEqual(proofCase.requiredPrimitives) + for (const primitive of proofCase.requiredPrimitives) { + expect(tusManagedUpload.primitives).toContain(primitive) + } + for (const featureId of proofCase.protocolFeatureIds) { + getClientFeature(featureId) + } + expect(tusManagedUpload.runtimeProfiles.map((profile) => profile.runtime)).toEqual( + proofCase.runtimeProfiles, + ) + } + }) }) From d93bd0f8dbf9dbbb6c9fa7f44866d83da5de6894 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:17:07 +0200 Subject: [PATCH 092/155] Update managed upload proof fixture --- test/spec/generated-protocol-contract.js | 186 +++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 0da0d6dee..d407de0a6 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1036,6 +1036,99 @@ export const tusManagedUpload = { ], scenarios: [ { + proof: { + attempts: [ + { + attemptIndex: 0, + failure: { + afterAcceptedOffset: 7, + kind: 'io-error', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/managed-durable-retry', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 7, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '7', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 1, + requests: [ + { + headers: {}, + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + statusCode: 200, + }, + url: 'upload', + }, + { + bodySize: 7, + headers: { + 'Upload-Offset': '7', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '14', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + stateAfterAttempt: 'succeeded', + }, + ], + cleanup: { + ownedSource: 'remove-owned-source-after-success', + resumeUrl: 'remove-after-success', + }, + input: { + chunkSize: 7, + content: 'hello managed!', + fingerprint: 'managed-durable-retry-fingerprint', + metadata: { + filename: 'managed.txt', + }, + uploadPath: 'managed-durable-retry', + }, + retryDelays: [0], + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + sourceDurability: 'copy-to-owned-storage', + stateBackend: 'filesystem', + states: ['pending', 'running', 'failed', 'running', 'succeeded'], + }, requiredPrimitives: [ 'accept-upload-submission', 'make-source-durable', @@ -1077,6 +1170,99 @@ export const tusManagedUploadProofCases = [ { featureId: 'managedUpload', layer: 'feature-over-protocol', + proof: { + attempts: [ + { + attemptIndex: 0, + failure: { + afterAcceptedOffset: 7, + kind: 'io-error', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/managed-durable-retry', + }, + statusCode: 201, + }, + url: 'endpoint', + }, + { + bodySize: 7, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '7', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 1, + requests: [ + { + headers: {}, + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + statusCode: 200, + }, + url: 'upload', + }, + { + bodySize: 7, + headers: { + 'Upload-Offset': '7', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '14', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + stateAfterAttempt: 'succeeded', + }, + ], + cleanup: { + ownedSource: 'remove-owned-source-after-success', + resumeUrl: 'remove-after-success', + }, + input: { + chunkSize: 7, + content: 'hello managed!', + fingerprint: 'managed-durable-retry-fingerprint', + metadata: { + filename: 'managed.txt', + }, + uploadPath: 'managed-durable-retry', + }, + retryDelays: [0], + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + sourceDurability: 'copy-to-owned-storage', + stateBackend: 'filesystem', + states: ['pending', 'running', 'failed', 'running', 'succeeded'], + }, protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], requiredPrimitives: [ 'accept-upload-submission', From 155ab61f91cbb30d1aa2e6933e8cceedba808bf7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:43:36 +0200 Subject: [PATCH 093/155] Update managed upload proof fixture --- test/spec/generated-protocol-contract.js | 335 ++++++++++++----------- 1 file changed, 170 insertions(+), 165 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index d407de0a6..98743d30f 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1036,99 +1036,194 @@ export const tusManagedUpload = { ], scenarios: [ { - proof: { - attempts: [ - { - attemptIndex: 0, - failure: { - afterAcceptedOffset: 7, - kind: 'io-error', - }, - requests: [ - { - bodySize: 0, - headers: { - 'Upload-Length': '14', + proofs: [ + { + attempts: [ + { + attemptIndex: 0, + failure: { + afterAcceptedOffset: 7, + kind: 'io-error', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/managed-durable-retry', + }, + statusCode: 201, + }, + url: 'endpoint', }, - operationId: 'createTusUpload', - response: { + { + bodySize: 7, headers: { - Location: 'https://tus.io/uploads/managed-durable-retry', + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '7', + }, + statusCode: 204, }, - statusCode: 201, + url: 'upload', }, - url: 'endpoint', - }, - { - bodySize: 7, - headers: { - 'Upload-Offset': '0', + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 1, + requests: [ + { + headers: {}, + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + statusCode: 200, + }, + url: 'upload', }, - operationId: 'patchTusUpload', - response: { + { + bodySize: 7, headers: { 'Upload-Offset': '7', }, - statusCode: 204, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '14', + }, + statusCode: 204, + }, + url: 'upload', }, - url: 'upload', - }, - ], - stateAfterAttempt: 'failed', + ], + stateAfterAttempt: 'succeeded', + }, + ], + cleanup: { + ownedSource: 'remove-owned-source-after-success', + resumeUrl: 'remove-after-success', }, - { - attemptIndex: 1, - requests: [ - { - headers: {}, - operationId: 'getTusUploadOffset', - response: { + input: { + chunkSize: 7, + content: 'hello managed!', + fingerprint: 'managed-durable-retry-fingerprint', + metadata: { + filename: 'managed.txt', + }, + uploadPath: 'managed-durable-retry', + }, + retryDelays: [0], + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed', 'running', 'succeeded'], + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + stateBackend: 'filesystem', + }, + { + attempts: [ + { + attemptIndex: 0, + failure: { + afterAcceptedOffset: 7, + kind: 'io-error', + }, + requests: [ + { + bodySize: 0, headers: { 'Upload-Length': '14', - 'Upload-Offset': '7', }, - statusCode: 200, + operationId: 'createTusUpload', + response: { + headers: { + Location: 'https://tus.io/uploads/managed-durable-retry', + }, + statusCode: 201, + }, + url: 'endpoint', }, - url: 'upload', - }, - { - bodySize: 7, - headers: { - 'Upload-Offset': '7', + { + bodySize: 7, + headers: { + 'Upload-Offset': '0', + }, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '7', + }, + statusCode: 204, + }, + url: 'upload', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 1, + requests: [ + { + headers: {}, + operationId: 'getTusUploadOffset', + response: { + headers: { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + statusCode: 200, + }, + url: 'upload', }, - operationId: 'patchTusUpload', - response: { + { + bodySize: 7, headers: { - 'Upload-Offset': '14', + 'Upload-Offset': '7', }, - statusCode: 204, + operationId: 'patchTusUpload', + response: { + headers: { + 'Upload-Offset': '14', + }, + statusCode: 204, + }, + url: 'upload', }, - url: 'upload', - }, - ], - stateAfterAttempt: 'succeeded', + ], + stateAfterAttempt: 'succeeded', + }, + ], + cleanup: { + ownedSource: 'remove-owned-source-after-success', + resumeUrl: 'remove-after-success', }, - ], - cleanup: { - ownedSource: 'remove-owned-source-after-success', - resumeUrl: 'remove-after-success', - }, - input: { - chunkSize: 7, - content: 'hello managed!', - fingerprint: 'managed-durable-retry-fingerprint', - metadata: { - filename: 'managed.txt', + input: { + chunkSize: 7, + content: 'hello managed!', + fingerprint: 'managed-durable-retry-fingerprint', + metadata: { + filename: 'managed.txt', + }, + uploadPath: 'managed-durable-retry', }, - uploadPath: 'managed-durable-retry', + retryDelays: [0], + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed', 'running', 'succeeded'], + runtime: 'android', + scheduler: 'durable-os-scheduler', + stateBackend: 'platform-key-value-store', }, - retryDelays: [0], - runtime: 'java', - scheduler: 'process-lifetime-worker-pool', - sourceDurability: 'copy-to-owned-storage', - stateBackend: 'filesystem', - states: ['pending', 'running', 'failed', 'running', 'succeeded'], - }, + ], requiredPrimitives: [ 'accept-upload-submission', 'make-source-durable', @@ -1170,99 +1265,7 @@ export const tusManagedUploadProofCases = [ { featureId: 'managedUpload', layer: 'feature-over-protocol', - proof: { - attempts: [ - { - attemptIndex: 0, - failure: { - afterAcceptedOffset: 7, - kind: 'io-error', - }, - requests: [ - { - bodySize: 0, - headers: { - 'Upload-Length': '14', - }, - operationId: 'createTusUpload', - response: { - headers: { - Location: 'https://tus.io/uploads/managed-durable-retry', - }, - statusCode: 201, - }, - url: 'endpoint', - }, - { - bodySize: 7, - headers: { - 'Upload-Offset': '0', - }, - operationId: 'patchTusUpload', - response: { - headers: { - 'Upload-Offset': '7', - }, - statusCode: 204, - }, - url: 'upload', - }, - ], - stateAfterAttempt: 'failed', - }, - { - attemptIndex: 1, - requests: [ - { - headers: {}, - operationId: 'getTusUploadOffset', - response: { - headers: { - 'Upload-Length': '14', - 'Upload-Offset': '7', - }, - statusCode: 200, - }, - url: 'upload', - }, - { - bodySize: 7, - headers: { - 'Upload-Offset': '7', - }, - operationId: 'patchTusUpload', - response: { - headers: { - 'Upload-Offset': '14', - }, - statusCode: 204, - }, - url: 'upload', - }, - ], - stateAfterAttempt: 'succeeded', - }, - ], - cleanup: { - ownedSource: 'remove-owned-source-after-success', - resumeUrl: 'remove-after-success', - }, - input: { - chunkSize: 7, - content: 'hello managed!', - fingerprint: 'managed-durable-retry-fingerprint', - metadata: { - filename: 'managed.txt', - }, - uploadPath: 'managed-durable-retry', - }, - retryDelays: [0], - runtime: 'java', - scheduler: 'process-lifetime-worker-pool', - sourceDurability: 'copy-to-owned-storage', - stateBackend: 'filesystem', - states: ['pending', 'running', 'failed', 'running', 'succeeded'], - }, + proofRuntimes: ['java', 'android'], protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], requiredPrimitives: [ 'accept-upload-submission', @@ -1279,6 +1282,7 @@ export const tusManagedUploadProofCases = [ { featureId: 'managedUpload', layer: 'feature-over-protocol', + proofRuntimes: [], protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], requiredPrimitives: [ 'accept-upload-submission', @@ -1293,6 +1297,7 @@ export const tusManagedUploadProofCases = [ { featureId: 'managedUpload', layer: 'feature-over-protocol', + proofRuntimes: [], protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], requiredPrimitives: [ 'accept-upload-submission', From dc2a04c62e62381ee50f2b4fd3a2e6294c84d254 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:09:50 +0200 Subject: [PATCH 094/155] Update managed upload proof fixture --- test/spec/generated-protocol-contract.js | 114 ++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 98743d30f..5a07ad720 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1044,6 +1044,7 @@ export const tusManagedUpload = { failure: { afterAcceptedOffset: 7, kind: 'io-error', + phase: 'after-accepted-offset', }, requests: [ { @@ -1126,6 +1127,9 @@ export const tusManagedUpload = { retryDelays: [0], sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'succeeded'], + terminal: { + state: 'succeeded', + }, runtime: 'java', scheduler: 'process-lifetime-worker-pool', stateBackend: 'filesystem', @@ -1137,6 +1141,7 @@ export const tusManagedUpload = { failure: { afterAcceptedOffset: 7, kind: 'io-error', + phase: 'after-accepted-offset', }, requests: [ { @@ -1219,6 +1224,9 @@ export const tusManagedUpload = { retryDelays: [0], sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'succeeded'], + terminal: { + state: 'succeeded', + }, runtime: 'android', scheduler: 'durable-os-scheduler', stateBackend: 'platform-key-value-store', @@ -1238,12 +1246,114 @@ export const tusManagedUpload = { 'Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.', }, { + proofs: [ + { + attempts: [ + { + attemptIndex: 0, + failure: { + kind: 'unretryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 400, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + ], + cleanup: { + ownedSource: 'retain-owned-source-after-permanent-failure', + resumeUrl: 'absent-after-permanent-failure', + }, + input: { + chunkSize: 7, + content: 'hello failure!', + fingerprint: 'managed-permanent-failure-fingerprint', + metadata: { + filename: 'managed-permanent-failure.txt', + }, + uploadPath: 'managed-permanent-failure', + }, + retryDelays: [], + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed'], + terminal: { + failure: 'unretryable-protocol-error', + state: 'failed', + }, + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + stateBackend: 'filesystem', + }, + { + attempts: [ + { + attemptIndex: 0, + failure: { + kind: 'unretryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 400, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + ], + cleanup: { + ownedSource: 'retain-owned-source-after-permanent-failure', + resumeUrl: 'absent-after-permanent-failure', + }, + input: { + chunkSize: 7, + content: 'hello failure!', + fingerprint: 'managed-permanent-failure-fingerprint', + metadata: { + filename: 'managed-permanent-failure.txt', + }, + uploadPath: 'managed-permanent-failure', + }, + retryDelays: [], + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed'], + terminal: { + failure: 'unretryable-protocol-error', + state: 'failed', + }, + runtime: 'android', + scheduler: 'durable-os-scheduler', + stateBackend: 'platform-key-value-store', + }, + ], requiredPrimitives: [ 'accept-upload-submission', 'make-source-durable', 'schedule-upload-work', + 'run-protocol-upload', 'classify-failure', 'publish-upload-state', + 'cleanup-managed-upload', ], scenarioId: 'managedUploadPermanentFailure', summary: @@ -1282,14 +1392,16 @@ export const tusManagedUploadProofCases = [ { featureId: 'managedUpload', layer: 'feature-over-protocol', - proofRuntimes: [], + proofRuntimes: ['java', 'android'], protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], requiredPrimitives: [ 'accept-upload-submission', 'make-source-durable', 'schedule-upload-work', + 'run-protocol-upload', 'classify-failure', 'publish-upload-state', + 'cleanup-managed-upload', ], runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], scenarioId: 'managedUploadPermanentFailure', From 9c423cb597db3c4a2cd42d3676d2b9d35b6578e4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:32:43 +0200 Subject: [PATCH 095/155] Update managed upload proof fixture --- test/spec/generated-protocol-contract.js | 222 +++++++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 5a07ad720..cc7a4952c 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -926,6 +926,7 @@ export const tusManagedUpload = { scenarioIds: [ 'managedUploadDurableRetry', 'managedUploadPermanentFailure', + 'managedUploadRetryPolicyExhausted', 'managedUploadNetworkConstraint', ], status: 'needs-generated-scenario', @@ -1359,6 +1360,209 @@ export const tusManagedUpload = { summary: 'Classify missing sources and unretryable protocol failures as terminal without further retry.', }, + { + proofs: [ + { + attempts: [ + { + attemptIndex: 0, + failure: { + kind: 'retryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 500, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 1, + failure: { + kind: 'retryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 500, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 2, + failure: { + kind: 'retryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 500, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + ], + cleanup: { + ownedSource: 'retain-owned-source-after-permanent-failure', + resumeUrl: 'absent-after-permanent-failure', + }, + input: { + chunkSize: 7, + content: 'hello retries!', + fingerprint: 'managed-retry-exhausted-fingerprint', + metadata: { + filename: 'managed-retry-exhausted.txt', + }, + uploadPath: 'managed-retry-exhausted', + }, + retryDelays: [0, 0], + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed', 'running', 'failed', 'running', 'failed'], + terminal: { + failure: 'retry-policy-exhausted', + state: 'failed', + }, + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + stateBackend: 'filesystem', + }, + { + attempts: [ + { + attemptIndex: 0, + failure: { + kind: 'retryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 500, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 1, + failure: { + kind: 'retryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 500, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + { + attemptIndex: 2, + failure: { + kind: 'retryable-protocol-error', + phase: 'during-protocol-request', + }, + requests: [ + { + bodySize: 0, + headers: { + 'Upload-Length': '14', + }, + operationId: 'createTusUpload', + response: { + headers: {}, + statusCode: 500, + }, + url: 'endpoint', + }, + ], + stateAfterAttempt: 'failed', + }, + ], + cleanup: { + ownedSource: 'retain-owned-source-after-permanent-failure', + resumeUrl: 'absent-after-permanent-failure', + }, + input: { + chunkSize: 7, + content: 'hello retries!', + fingerprint: 'managed-retry-exhausted-fingerprint', + metadata: { + filename: 'managed-retry-exhausted.txt', + }, + uploadPath: 'managed-retry-exhausted', + }, + retryDelays: [0, 0], + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed', 'running', 'failed', 'running', 'failed'], + terminal: { + failure: 'retry-policy-exhausted', + state: 'failed', + }, + runtime: 'android', + scheduler: 'durable-os-scheduler', + stateBackend: 'platform-key-value-store', + }, + ], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + scenarioId: 'managedUploadRetryPolicyExhausted', + summary: + 'Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.', + }, { requiredPrimitives: [ 'accept-upload-submission', @@ -1406,6 +1610,24 @@ export const tusManagedUploadProofCases = [ runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], scenarioId: 'managedUploadPermanentFailure', }, + { + featureId: 'managedUpload', + layer: 'feature-over-protocol', + proofRuntimes: ['java', 'android'], + protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadRetryPolicyExhausted', + }, { featureId: 'managedUpload', layer: 'feature-over-protocol', From 22400908755cef7ce1b1a9728a108343512c4236 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:53:29 +0200 Subject: [PATCH 096/155] Update generated protocol contract fixture --- test/spec/generated-protocol-contract.js | 116 ++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index cc7a4952c..5d649ff84 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -881,6 +881,7 @@ export const tusManagedUpload = { capabilities: { cleanup: { policies: [ + 'absent-after-source-unavailable', 'remove-owned-source-after-success', 'remove-owned-source-after-cancel', 'retain-owned-source-after-permanent-failure', @@ -927,6 +928,7 @@ export const tusManagedUpload = { 'managedUploadDurableRetry', 'managedUploadPermanentFailure', 'managedUploadRetryPolicyExhausted', + 'managedUploadSourceUnavailable', 'managedUploadNetworkConstraint', ], status: 'needs-generated-scenario', @@ -1126,6 +1128,7 @@ export const tusManagedUpload = { uploadPath: 'managed-durable-retry', }, retryDelays: [0], + sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'succeeded'], terminal: { @@ -1223,6 +1226,7 @@ export const tusManagedUpload = { uploadPath: 'managed-durable-retry', }, retryDelays: [0], + sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'succeeded'], terminal: { @@ -1287,6 +1291,7 @@ export const tusManagedUpload = { uploadPath: 'managed-permanent-failure', }, retryDelays: [], + sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed'], terminal: { @@ -1336,6 +1341,7 @@ export const tusManagedUpload = { uploadPath: 'managed-permanent-failure', }, retryDelays: [], + sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed'], terminal: { @@ -1357,8 +1363,7 @@ export const tusManagedUpload = { 'cleanup-managed-upload', ], scenarioId: 'managedUploadPermanentFailure', - summary: - 'Classify missing sources and unretryable protocol failures as terminal without further retry.', + summary: 'Classify unretryable protocol failures as terminal without further retry.', }, { proofs: [ @@ -1445,6 +1450,7 @@ export const tusManagedUpload = { uploadPath: 'managed-retry-exhausted', }, retryDelays: [0, 0], + sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'failed', 'running', 'failed'], terminal: { @@ -1538,6 +1544,7 @@ export const tusManagedUpload = { uploadPath: 'managed-retry-exhausted', }, retryDelays: [0, 0], + sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'failed', 'running', 'failed'], terminal: { @@ -1563,6 +1570,95 @@ export const tusManagedUpload = { summary: 'Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.', }, + { + proofs: [ + { + attempts: [ + { + attemptIndex: 0, + failure: { + kind: 'source-unavailable', + phase: 'before-protocol-request', + }, + requests: [], + stateAfterAttempt: 'failed', + }, + ], + cleanup: { + ownedSource: 'absent-after-source-unavailable', + resumeUrl: 'absent-after-permanent-failure', + }, + input: { + chunkSize: 7, + content: 'hello missing!', + fingerprint: 'managed-source-unavailable-fingerprint', + metadata: { + filename: 'managed-source-unavailable.txt', + }, + uploadPath: 'managed-source-unavailable', + }, + retryDelays: [], + sourceAvailability: 'missing-before-durable-copy', + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed'], + terminal: { + failure: 'source-unavailable', + state: 'failed', + }, + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + stateBackend: 'filesystem', + }, + { + attempts: [ + { + attemptIndex: 0, + failure: { + kind: 'source-unavailable', + phase: 'before-protocol-request', + }, + requests: [], + stateAfterAttempt: 'failed', + }, + ], + cleanup: { + ownedSource: 'absent-after-source-unavailable', + resumeUrl: 'absent-after-permanent-failure', + }, + input: { + chunkSize: 7, + content: 'hello missing!', + fingerprint: 'managed-source-unavailable-fingerprint', + metadata: { + filename: 'managed-source-unavailable.txt', + }, + uploadPath: 'managed-source-unavailable', + }, + retryDelays: [], + sourceAvailability: 'missing-before-durable-copy', + sourceDurability: 'copy-to-owned-storage', + states: ['pending', 'running', 'failed'], + terminal: { + failure: 'source-unavailable', + state: 'failed', + }, + runtime: 'android', + scheduler: 'durable-os-scheduler', + stateBackend: 'platform-key-value-store', + }, + ], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + scenarioId: 'managedUploadSourceUnavailable', + summary: + 'Classify source disappearance before protocol requests as terminal without issuing a TUS request.', + }, { requiredPrimitives: [ 'accept-upload-submission', @@ -1628,6 +1724,22 @@ export const tusManagedUploadProofCases = [ runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], scenarioId: 'managedUploadRetryPolicyExhausted', }, + { + featureId: 'managedUpload', + layer: 'feature-over-protocol', + proofRuntimes: ['java', 'android'], + protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadSourceUnavailable', + }, { featureId: 'managedUpload', layer: 'feature-over-protocol', From 96e1c772d37ff05fcb6fa195a6dc0aa1d65d6a77 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 14:22:41 +0200 Subject: [PATCH 097/155] Update generated managed upload contract --- test/spec/generated-protocol-contract.js | 150 ++++++++++++++++++----- 1 file changed, 118 insertions(+), 32 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 5d649ff84..fc9dbc949 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -884,6 +884,7 @@ export const tusManagedUpload = { 'absent-after-source-unavailable', 'remove-owned-source-after-success', 'remove-owned-source-after-cancel', + 'retain-owned-source-while-deferred', 'retain-owned-source-after-permanent-failure', 'retain-source-after-retryable-failure', 'remove-managed-state-after-terminal-retention', @@ -931,7 +932,7 @@ export const tusManagedUpload = { 'managedUploadSourceUnavailable', 'managedUploadNetworkConstraint', ], - status: 'needs-generated-scenario', + status: 'covered-by-generated-scenario', }, description: 'Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.', @@ -1127,13 +1128,19 @@ export const tusManagedUpload = { }, uploadPath: 'managed-durable-retry', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + kind: 'terminal', + state: 'succeeded', + }, retryDelays: [0], sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'succeeded'], - terminal: { - state: 'succeeded', - }, runtime: 'java', scheduler: 'process-lifetime-worker-pool', stateBackend: 'filesystem', @@ -1225,13 +1232,19 @@ export const tusManagedUpload = { }, uploadPath: 'managed-durable-retry', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + kind: 'terminal', + state: 'succeeded', + }, retryDelays: [0], sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'succeeded'], - terminal: { - state: 'succeeded', - }, runtime: 'android', scheduler: 'durable-os-scheduler', stateBackend: 'platform-key-value-store', @@ -1290,14 +1303,20 @@ export const tusManagedUpload = { }, uploadPath: 'managed-permanent-failure', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + failure: 'unretryable-protocol-error', + kind: 'terminal', + state: 'failed', + }, retryDelays: [], sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed'], - terminal: { - failure: 'unretryable-protocol-error', - state: 'failed', - }, runtime: 'java', scheduler: 'process-lifetime-worker-pool', stateBackend: 'filesystem', @@ -1340,14 +1359,20 @@ export const tusManagedUpload = { }, uploadPath: 'managed-permanent-failure', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + failure: 'unretryable-protocol-error', + kind: 'terminal', + state: 'failed', + }, retryDelays: [], sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed'], - terminal: { - failure: 'unretryable-protocol-error', - state: 'failed', - }, runtime: 'android', scheduler: 'durable-os-scheduler', stateBackend: 'platform-key-value-store', @@ -1449,14 +1474,20 @@ export const tusManagedUpload = { }, uploadPath: 'managed-retry-exhausted', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + failure: 'retry-policy-exhausted', + kind: 'terminal', + state: 'failed', + }, retryDelays: [0, 0], sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'failed', 'running', 'failed'], - terminal: { - failure: 'retry-policy-exhausted', - state: 'failed', - }, runtime: 'java', scheduler: 'process-lifetime-worker-pool', stateBackend: 'filesystem', @@ -1543,14 +1574,20 @@ export const tusManagedUpload = { }, uploadPath: 'managed-retry-exhausted', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + failure: 'retry-policy-exhausted', + kind: 'terminal', + state: 'failed', + }, retryDelays: [0, 0], sourceAvailability: 'available', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed', 'running', 'failed', 'running', 'failed'], - terminal: { - failure: 'retry-policy-exhausted', - state: 'failed', - }, runtime: 'android', scheduler: 'durable-os-scheduler', stateBackend: 'platform-key-value-store', @@ -1597,14 +1634,20 @@ export const tusManagedUpload = { }, uploadPath: 'managed-source-unavailable', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + failure: 'source-unavailable', + kind: 'terminal', + state: 'failed', + }, retryDelays: [], sourceAvailability: 'missing-before-durable-copy', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed'], - terminal: { - failure: 'source-unavailable', - state: 'failed', - }, runtime: 'java', scheduler: 'process-lifetime-worker-pool', stateBackend: 'filesystem', @@ -1634,14 +1677,20 @@ export const tusManagedUpload = { }, uploadPath: 'managed-source-unavailable', }, + network: { + current: 'unmetered-network', + decision: 'start-upload-work', + required: 'any-network', + }, + outcome: { + failure: 'source-unavailable', + kind: 'terminal', + state: 'failed', + }, retryDelays: [], sourceAvailability: 'missing-before-durable-copy', sourceDurability: 'copy-to-owned-storage', states: ['pending', 'running', 'failed'], - terminal: { - failure: 'source-unavailable', - state: 'failed', - }, runtime: 'android', scheduler: 'durable-os-scheduler', stateBackend: 'platform-key-value-store', @@ -1660,8 +1709,44 @@ export const tusManagedUpload = { 'Classify source disappearance before protocol requests as terminal without issuing a TUS request.', }, { + proofs: [ + { + attempts: [], + cleanup: { + ownedSource: 'retain-owned-source-while-deferred', + resumeUrl: 'absent-while-deferred', + }, + input: { + chunkSize: 7, + content: 'hello later!', + fingerprint: 'managed-network-constraint-fingerprint', + metadata: { + filename: 'managed-network-constraint.txt', + }, + uploadPath: 'managed-network-constraint', + }, + network: { + current: 'metered-network', + decision: 'defer-until-network-constraint-satisfied', + required: 'unmetered-network', + }, + outcome: { + kind: 'deferred', + reason: 'network-constraint-unsatisfied', + state: 'pending', + }, + retryDelays: [], + sourceAvailability: 'available', + sourceDurability: 'copy-to-owned-storage', + states: ['pending'], + runtime: 'android', + scheduler: 'durable-os-scheduler', + stateBackend: 'platform-key-value-store', + }, + ], requiredPrimitives: [ 'accept-upload-submission', + 'make-source-durable', 'schedule-upload-work', 'publish-upload-state', ], @@ -1743,10 +1828,11 @@ export const tusManagedUploadProofCases = [ { featureId: 'managedUpload', layer: 'feature-over-protocol', - proofRuntimes: [], + proofRuntimes: ['android'], protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], requiredPrimitives: [ 'accept-upload-submission', + 'make-source-durable', 'schedule-upload-work', 'publish-upload-state', ], From 4f080f0132f14f50b8fef862f7a279bd90b6eb78 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 23:21:36 +0200 Subject: [PATCH 098/155] Add devdock Transloadit TUS example --- .../main.js | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 examples/api2-devdock-transloadit-assembly-upload/main.js diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.js b/examples/api2-devdock-transloadit-assembly-upload/main.js new file mode 100644 index 000000000..09397b42d --- /dev/null +++ b/examples/api2-devdock-transloadit-assembly-upload/main.js @@ -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) +}) From 346e13f2a4f0e68997c9a5130e84eb9268a69476 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 23:30:03 +0200 Subject: [PATCH 099/155] Normalize generated request facts --- test/spec/generated-protocol-contract.js | 720 +++++++++++++++++- test/spec/test-generated-protocol-contract.js | 8 +- 2 files changed, 722 insertions(+), 6 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index fc9dbc949..edf016372 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1918,31 +1918,56 @@ export const tusClientConformanceScenarios = [ ], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/generated-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'singleUploadLifecycle', @@ -1998,20 +2023,32 @@ export const tusClientConformanceScenarios = [ primitives: ['upload-during-creation', 'emit-progress'], requests: [ { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/creation-with-upload-contract', 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, ], scenarioId: 'creationWithUpload', @@ -2113,52 +2150,86 @@ export const tusClientConformanceScenarios = [ primitives: ['upload-during-creation', 'emit-progress'], requests: [ { + absentHeaders: [], + abort: false, bodySize: 5, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/creation-with-upload-partial-contract', 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 5, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '10', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', url: 'upload', + requestIndex: 1, }, { + absentHeaders: [], + abort: false, bodySize: 1, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-final-chunk', uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', url: 'upload', + requestIndex: 2, }, ], scenarioId: 'creationWithUploadPartialChunk', @@ -2216,7 +2287,9 @@ export const tusClientConformanceScenarios = [ requests: [ { absentHeaders: ['Tus-Resumable'], + abort: false, bodySize: 11, + errorMessage: null, headerMode: 'exact', headers: { 'Content-Type': 'application/partial-upload', @@ -2224,16 +2297,23 @@ export const tusClientConformanceScenarios = [ 'Upload-Draft-Interop-Version': '6', 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, headerMode: 'exact', headers: { Location: 'https://tus.io/uploads/ietf-draft-05-contract', 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, ], scenarioId: 'ietfDraft05CreationWithUpload', @@ -2296,38 +2376,57 @@ export const tusClientConformanceScenarios = [ requests: [ { absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: null, + errorMessage: null, headerMode: 'exact', headers: { 'Upload-Draft-Interop-Version': '5', }, + headersSpecified: true, + method: null, operationId: 'getTusUploadOffset', response: { + body: null, headerMode: 'exact', headers: { 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 200, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 0, }, { absentHeaders: ['Content-Type', 'Tus-Resumable'], + abort: false, bodySize: 6, + errorMessage: null, headerMode: 'exact', headers: { 'Upload-Complete': '?1', 'Upload-Draft-Interop-Version': '5', 'Upload-Offset': '5', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, headerMode: 'exact', headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'ietfDraft03ResumeWithoutKnownLength', @@ -2577,16 +2676,29 @@ export const tusClientConformanceScenarios = [ primitives: ['report-detailed-errors'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', 'X-Request-ID': 'contract-request-id', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { body: 'server_error', + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 500, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, ], scenarioId: 'detailedCreateResponseError', @@ -2619,15 +2731,23 @@ export const tusClientConformanceScenarios = [ primitives: ['report-detailed-errors'], requests: [ { - error: { - message: 'socket down', - }, + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: 'socket down', + headerMode: null, headers: { 'Upload-Length': '11', 'X-Request-ID': 'contract-request-id', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', + response: null, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, ], scenarioId: 'detailedCreateRequestError', @@ -2652,32 +2772,57 @@ export const tusClientConformanceScenarios = [ primitives: ['send-upload-body-headers'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/upload-body-headers-contract', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'uploadBodyHeaders', @@ -2706,36 +2851,61 @@ export const tusClientConformanceScenarios = [ primitives: ['apply-custom-request-headers'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/custom-headers-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'customRequestHeaders', @@ -2830,29 +3000,55 @@ export const tusClientConformanceScenarios = [ primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'getTusUploadOffset', response: { + body: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 200, }, + role: 'recover-upload-offset', + uploadUrl: null, url: 'upload', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 6, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '5', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'resumeFromPreviousUpload', @@ -2914,31 +3110,56 @@ export const tusClientConformanceScenarios = [ primitives: ['resolve-relative-location'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'relative-contract', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'relativeLocationResolution', @@ -2978,31 +3199,56 @@ export const tusClientConformanceScenarios = [ primitives: ['read-browser-file'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/array-buffer-contract', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'arrayBufferInput', @@ -3042,31 +3288,56 @@ export const tusClientConformanceScenarios = [ primitives: ['read-browser-file'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/array-buffer-view-contract', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'arrayBufferViewInput', @@ -3109,32 +3380,56 @@ export const tusClientConformanceScenarios = [ requests: [ { absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Defer-Length': '1', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/web-stream-contract', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'webReadableStreamInput', @@ -3177,32 +3472,56 @@ export const tusClientConformanceScenarios = [ requests: [ { absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Defer-Length': '1', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/node-stream-contract', }, + headersSpecified: true, statusCode: 201, }, + role: null, + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: null, + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], runtimes: ['node'], @@ -3243,31 +3562,56 @@ export const tusClientConformanceScenarios = [ primitives: ['read-node-file'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/node-path-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], runtimes: ['node'], @@ -3333,32 +3677,56 @@ export const tusClientConformanceScenarios = [ requests: [ { absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Defer-Length': '1', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/deferred-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, ], scenarioId: 'deferredLengthUpload', @@ -3382,34 +3750,57 @@ export const tusClientConformanceScenarios = [ primitives: ['override-patch-method'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'getTusUploadOffset', response: { + body: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '3', }, + headersSpecified: true, statusCode: 200, }, + role: 'recover-upload-offset', uploadUrl: 'https://tus.io/uploads/override-contract', url: 'upload', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 8, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '3', 'X-HTTP-Method-Override': 'PATCH', }, + headersSpecified: true, method: 'POST', operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', uploadUrl: 'https://tus.io/uploads/override-contract', url: 'upload', + requestIndex: 1, }, ], scenarioId: 'overridePatchMethod', @@ -3487,80 +3878,140 @@ export const tusClientConformanceScenarios = [ primitives: ['concatenate-partial-uploads', 'emit-progress'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Concat': 'partial', 'Upload-Length': '5', 'Upload-Metadata': 'test d29ybGQ=', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/parallel-part-1', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-partial-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Concat': 'partial', 'Upload-Length': '6', 'Upload-Metadata': 'test d29ybGQ=', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/parallel-part-2', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-partial-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 1, }, { + absentHeaders: [], + abort: false, bodySize: 5, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-part-1', url: 'upload', + requestIndex: 2, }, { + absentHeaders: [], + abort: false, bodySize: 6, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '6', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-part-2', url: 'upload', + requestIndex: 3, }, { absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', 'Upload-Metadata': 'foo aGVsbG8=', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/parallel-final', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-final-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 4, }, ], scenarioId: 'parallelUploadConcat', @@ -3616,6 +4067,11 @@ export const tusClientConformanceScenarios = [ primitives: ['abort-current-request', 'terminate-upload', 'concatenate-partial-uploads'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Concat': 'partial', 'Upload-Length': '5', @@ -3623,16 +4079,29 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/parallel-cleanup-part-1', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-partial-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Concat': 'partial', 'Upload-Length': '6', @@ -3640,17 +4109,29 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/parallel-cleanup-part-2', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-partial-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 1, }, { + absentHeaders: [], + abort: false, bodySize: 5, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', @@ -3658,17 +4139,27 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + headersSpecified: true, method: 'POST', operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 500, }, + role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', url: 'upload', + requestIndex: 2, }, { + absentHeaders: [], abort: true, bodySize: 6, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', @@ -3676,34 +4167,64 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + headersSpecified: true, method: 'POST', operationId: 'patchTusUpload', + response: null, + role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', url: 'upload', + requestIndex: 3, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + headersSpecified: true, + method: null, operationId: 'terminateTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 204, }, + role: 'terminate-upload', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', url: 'upload', + requestIndex: 4, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + headersSpecified: true, + method: null, operationId: 'terminateTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 204, }, + role: 'terminate-upload', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', url: 'upload', + requestIndex: 5, }, ], scenarioId: 'parallelUploadAbortCleanup', @@ -3759,75 +4280,154 @@ export const tusClientConformanceScenarios = [ primitives: ['retry-with-backoff', 'recover-offset-after-error'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/retry-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 500, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'getTusUploadOffset', response: { + body: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 200, }, + role: 'recover-upload-offset', + uploadUrl: null, url: 'upload', + requestIndex: 2, }, { + absentHeaders: [], + abort: false, bodySize: 6, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '5', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 500, }, + role: 'retry-upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 3, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'getTusUploadOffset', response: { + body: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 200, }, + role: 'recover-upload-offset', + uploadUrl: null, url: 'upload', + requestIndex: 4, }, { + absentHeaders: [], + abort: false, bodySize: 6, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '5', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-final-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 5, }, ], scenarioId: 'retryPatchAfterOffsetRecovery', @@ -3869,15 +4469,29 @@ export const tusClientConformanceScenarios = [ primitives: ['run-request-hooks'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'getTusUploadOffset', response: { + body: null, + headerMode: null, headers: { 'Upload-Length': '11', 'Upload-Offset': '11', }, + headersSpecified: true, statusCode: 200, }, + role: 'recover-upload-offset', + uploadUrl: null, url: 'upload', + requestIndex: 0, }, ], scenarioId: 'requestLifecycleHooks', @@ -3915,12 +4529,22 @@ export const tusClientConformanceScenarios = [ primitives: ['abort-current-request'], requests: [ { + absentHeaders: [], abort: true, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', + response: null, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, ], scenarioId: 'abortUpload', @@ -3966,21 +4590,37 @@ export const tusClientConformanceScenarios = [ primitives: ['abort-current-request', 'terminate-upload'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/abort-terminate-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], abort: true, bodySize: 11, + errorMessage: null, + headerMode: null, headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', @@ -3988,20 +4628,39 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, + headersSpecified: true, method: 'POST', operationId: 'patchTusUpload', + response: null, + role: 'abort-upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, + headersSpecified: true, + method: null, operationId: 'terminateTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 204, }, + role: 'terminate-upload', + uploadUrl: null, url: 'upload', + requestIndex: 2, }, ], scenarioId: 'abortUploadAfterStoredUrl', @@ -4048,45 +4707,100 @@ export const tusClientConformanceScenarios = [ primitives: ['terminate-upload', 'retry-with-backoff'], requests: [ { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, headers: { 'Upload-Length': '11', }, + headersSpecified: true, + method: null, operationId: 'createTusUpload', response: { + body: null, + headerMode: null, headers: { Location: 'https://tus.io/uploads/terminate-contract', }, + headersSpecified: true, statusCode: 201, }, + role: 'create-upload', + uploadUrl: null, url: 'endpoint', + requestIndex: 0, }, { + absentHeaders: [], + abort: false, bodySize: 5, + errorMessage: null, + headerMode: null, headers: { 'Upload-Offset': '0', }, + headersSpecified: true, + method: null, operationId: 'patchTusUpload', response: { + body: null, + headerMode: null, headers: { 'Upload-Offset': '5', }, + headersSpecified: true, statusCode: 204, }, + role: 'upload-chunk', + uploadUrl: null, url: 'upload', + requestIndex: 1, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'terminateTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 423, }, + role: 'terminate-upload', + uploadUrl: null, url: 'upload', + requestIndex: 2, }, { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, operationId: 'terminateTusUpload', response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, statusCode: 204, }, + role: 'retry-terminate-upload', + uploadUrl: null, url: 'upload', + requestIndex: 3, }, ], scenarioId: 'terminateWithRetry', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 703ebbb42..d4ead4bd8 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -743,7 +743,9 @@ async function runGeneratedConformanceScenario(scenario) { const abortPromises = [] try { - for (const [requestIndex, request] of scenario.requests.entries()) { + for (const [scenarioRequestIndex, request] of scenario.requests.entries()) { + const requestIndex = request.requestIndex + expect(requestIndex).toBe(scenarioRequestIndex) const req = await testStack.nextRequest() expectScenarioRequest(req, scenario, request) @@ -751,8 +753,8 @@ async function runGeneratedConformanceScenario(scenario) { abortPromises.push( abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload), ) - } else if (request.error) { - req.responseError(new Error(request.error.message)) + } else if (request.errorMessage) { + req.responseError(new Error(request.errorMessage)) } else if (!request.response) { throw new Error( `Generated scenario ${scenario.scenarioId} request ${requestIndex} has no response, error, or abort`, From 62727432f19daef19576011b8044fd9204e0aa1c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 09:16:06 +0200 Subject: [PATCH 100/155] Regenerate TUS protocol fixtures --- lib/protocol_generated.ts | 86 +-- test/spec/generated-protocol-contract.js | 798 +++++++++++------------ 2 files changed, 443 insertions(+), 441 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index ce7a91bc2..c8428fdf6 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -894,75 +894,77 @@ export function tusValidateUploadStart({ uploadDataDuringCreation, uploadLengthDeferred, }: TusUploadStartValidationInput): TusUploadStartValidationResult { - if (!hasFile) { + if (hasFile === false) { return tusUploadStartValidationError('missingInput', TUS_FLOW_POLICY.messages.missingInput) } if (!tusSupportsProtocol(protocol)) { return tusUploadStartValidationError( 'unsupportedProtocol', - `${TUS_FLOW_POLICY.messages.unsupportedProtocolPrefix}${protocol}`, + TUS_FLOW_POLICY.messages.unsupportedProtocolPrefix + protocol, ) } - if (!hasEndpoint && !hasUploadUrl && !hasCurrentUrl) { + if (hasEndpoint === false && hasUploadUrl === false && hasCurrentUrl === false) { return tusUploadStartValidationError( 'missingEndpointOrUploadUrl', TUS_FLOW_POLICY.messages.missingEndpointOrUploadUrl, ) } - if (retryDelays != null && !Array.isArray(retryDelays)) { + if (!(retryDelays == null || Array.isArray(retryDelays))) { return tusUploadStartValidationError( 'retryDelaysNotArray', TUS_FLOW_POLICY.messages.retryDelaysNotArray, ) } - if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads) { - if (hasUploadUrl) { - return tusUploadStartValidationError( - 'parallelUploadsWithUploadUrl', - TUS_FLOW_POLICY.messages.parallelUploadsWithUploadUrl, - ) - } + if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && hasUploadUrl === true) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadUrl', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadUrl, + ) + } - if (hasUploadSize) { - return tusUploadStartValidationError( - 'parallelUploadsWithUploadSize', - TUS_FLOW_POLICY.messages.parallelUploadsWithUploadSize, - ) - } + if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && hasUploadSize === true) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadSize', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadSize, + ) + } - if (uploadLengthDeferred) { - return tusUploadStartValidationError( - 'parallelUploadsWithDeferredLength', - TUS_FLOW_POLICY.messages.parallelUploadsWithDeferredLength, - ) - } + if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && uploadLengthDeferred === true) { + return tusUploadStartValidationError( + 'parallelUploadsWithDeferredLength', + TUS_FLOW_POLICY.messages.parallelUploadsWithDeferredLength, + ) + } - if (uploadDataDuringCreation) { - return tusUploadStartValidationError( - 'parallelUploadsWithUploadDataDuringCreation', - TUS_FLOW_POLICY.messages.parallelUploadsWithUploadDataDuringCreation, - ) - } + if ( + parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && + uploadDataDuringCreation === true + ) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadDataDuringCreation', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadDataDuringCreation, + ) } - if (parallelUploadBoundariesCount != null) { - if (parallelUploads < TUS_FLOW_POLICY.minimumParallelUploads) { - return tusUploadStartValidationError( - 'parallelBoundariesWithoutParallelUploads', - TUS_FLOW_POLICY.messages.parallelBoundariesWithoutParallelUploads, - ) - } + if ( + parallelUploadBoundariesCount != null && + parallelUploads < TUS_FLOW_POLICY.minimumParallelUploads + ) { + return tusUploadStartValidationError( + 'parallelBoundariesWithoutParallelUploads', + TUS_FLOW_POLICY.messages.parallelBoundariesWithoutParallelUploads, + ) + } - if (parallelUploads !== parallelUploadBoundariesCount) { - return tusUploadStartValidationError( - 'parallelBoundariesLengthMismatch', - TUS_FLOW_POLICY.messages.parallelBoundariesLengthMismatch, - ) - } + if (parallelUploadBoundariesCount != null && parallelUploads !== parallelUploadBoundariesCount) { + return tusUploadStartValidationError( + 'parallelBoundariesLengthMismatch', + TUS_FLOW_POLICY.messages.parallelBoundariesLengthMismatch, + ) } return { ok: true } diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index edf016372..e7628e0bb 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1848,50 +1848,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/generated-contract', }, - events: [ - { - fingerprint: 'contract-single-fingerprint', - kind: 'fingerprint', - key: 'fingerprint:contract-single-fingerprint', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - fingerprint: 'contract-single-fingerprint', - kind: 'url-storage-add', - uploadUrl: 'https://tus.io/uploads/generated-contract', - key: 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', - }, - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 11, - kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -1971,14 +1927,22 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'singleUploadLifecycle', - }, - { - behavior: 'creation-with-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', - }, events: [ + { + fingerprint: 'contract-single-fingerprint', + kind: 'fingerprint', + key: 'fingerprint:contract-single-fingerprint', + }, + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + fingerprint: 'contract-single-fingerprint', + kind: 'url-storage-add', + uploadUrl: 'https://tus.io/uploads/generated-contract', + key: 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', + }, { bytesSent: 0, bytesTotal: 11, @@ -1992,8 +1956,11 @@ export const tusClientConformanceScenarios = [ key: 'progress:11:11', }, { - kind: 'upload-url-available', - key: 'upload-url-available', + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', }, { kind: 'success', @@ -2004,6 +1971,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'creation-with-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', + }, eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2052,13 +2026,6 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'creationWithUpload', - }, - { - behavior: 'creation-with-upload-partial-chunk', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', - }, events: [ { bytesSent: 0, @@ -2066,48 +2033,6 @@ export const tusClientConformanceScenarios = [ kind: 'progress', key: 'progress:0:11', }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesAccepted: 5, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:5:11', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, - { - bytesAccepted: 10, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:10:11', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, { bytesSent: 11, bytesTotal: 11, @@ -2115,11 +2040,8 @@ export const tusClientConformanceScenarios = [ key: 'progress:11:11', }, { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 1, - kind: 'chunk-complete', - key: 'chunk-complete:1:11:11', + kind: 'upload-url-available', + key: 'upload-url-available', }, { kind: 'success', @@ -2130,6 +2052,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'creation-with-upload-partial-chunk', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + }, eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2233,13 +2162,6 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'creationWithUploadPartialChunk', - }, - { - behavior: 'creation-with-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', - }, events: [ { bytesSent: 0, @@ -2248,15 +2170,60 @@ export const tusClientConformanceScenarios = [ key: 'progress:0:11', }, { - bytesSent: 11, + bytesSent: 5, bytesTotal: 11, kind: 'progress', - key: 'progress:11:11', + key: 'progress:5:11', }, { kind: 'upload-url-available', key: 'upload-url-available', }, + { + bytesAccepted: 5, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:5:11', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesSent: 10, + bytesTotal: 11, + kind: 'progress', + key: 'progress:10:11', + }, + { + bytesAccepted: 10, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:10:11', + }, + { + bytesSent: 10, + bytesTotal: 11, + kind: 'progress', + key: 'progress:10:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 1, + kind: 'chunk-complete', + key: 'chunk-complete:1:11:11', + }, { kind: 'success', key: 'success', @@ -2266,6 +2233,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'creation-with-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', + }, eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2317,23 +2291,12 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'ietfDraft05CreationWithUpload', - }, - { - behavior: 'upload-body-headers', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', - }, events: [ { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 5, + bytesSent: 0, bytesTotal: 11, kind: 'progress', - key: 'progress:5:11', + key: 'progress:0:11', }, { bytesSent: 11, @@ -2342,11 +2305,8 @@ export const tusClientConformanceScenarios = [ key: 'progress:11:11', }, { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 6, - kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', + kind: 'upload-url-available', + key: 'upload-url-available', }, { kind: 'success', @@ -2357,6 +2317,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'upload-body-headers', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2430,6 +2397,39 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'ietfDraft03ResumeWithoutKnownLength', + events: [ + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 6, + kind: 'chunk-complete', + key: 'chunk-complete:6:11:11', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], }, { behavior: 'start-option-validation', @@ -2438,7 +2438,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: no file or stream to upload provided', reason: 'missingInput', }, - events: [], featureId: 'startOptionValidation', input: { content: '', @@ -2449,6 +2448,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationMissingInput', + events: [], }, { behavior: 'start-option-validation', @@ -2457,7 +2457,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: neither an endpoint or an upload URL is provided', reason: 'missingEndpointOrUploadUrl', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2467,6 +2466,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationMissingEndpointOrUploadUrl', + events: [], }, { behavior: 'start-option-validation', @@ -2475,7 +2475,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: unsupported protocol tus-v9', reason: 'unsupportedProtocol', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2487,6 +2486,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationUnsupportedProtocol', + events: [], }, { behavior: 'start-option-validation', @@ -2495,7 +2495,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: the `retryDelays` option must either be an array or null', reason: 'retryDelaysNotArray', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2509,6 +2508,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationRetryDelaysNotArray', + events: [], }, { behavior: 'start-option-validation', @@ -2517,7 +2517,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', reason: 'parallelUploadsWithUploadUrl', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2530,6 +2529,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationParallelUploadsWithUploadUrl', + events: [], }, { behavior: 'start-option-validation', @@ -2538,7 +2538,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', reason: 'parallelUploadsWithUploadSize', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2551,6 +2550,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationParallelUploadsWithUploadSize', + events: [], }, { behavior: 'start-option-validation', @@ -2559,7 +2559,6 @@ export const tusClientConformanceScenarios = [ message: 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', reason: 'parallelUploadsWithDeferredLength', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2572,6 +2571,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationParallelUploadsWithDeferredLength', + events: [], }, { behavior: 'start-option-validation', @@ -2581,7 +2581,6 @@ export const tusClientConformanceScenarios = [ 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', reason: 'parallelUploadsWithUploadDataDuringCreation', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2594,6 +2593,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', + events: [], }, { behavior: 'start-option-validation', @@ -2603,7 +2603,6 @@ export const tusClientConformanceScenarios = [ 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', reason: 'parallelBoundariesWithoutParallelUploads', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2620,6 +2619,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', + events: [], }, { behavior: 'start-option-validation', @@ -2629,7 +2629,6 @@ export const tusClientConformanceScenarios = [ 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', reason: 'parallelBoundariesLengthMismatch', }, - events: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2647,6 +2646,7 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], scenarioId: 'startValidationParallelBoundariesLengthMismatch', + events: [], }, { behavior: 'detailed-error', @@ -2656,7 +2656,6 @@ export const tusClientConformanceScenarios = [ 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', reason: 'unexpectedCreateResponse', }, - events: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -2702,6 +2701,7 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'detailedCreateResponseError', + events: [], }, { behavior: 'detailed-error', @@ -2711,7 +2711,6 @@ export const tusClientConformanceScenarios = [ 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', reason: 'createUploadRequestFailed', }, - events: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -2751,6 +2750,7 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'detailedCreateRequestError', + events: [], }, { behavior: 'upload-body-headers', @@ -2758,7 +2758,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', }, - events: [], featureId: 'uploadBodyHeaders', input: { content: 'hello world', @@ -2826,6 +2825,7 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'uploadBodyHeaders', + events: [], }, { behavior: 'custom-request-headers', @@ -2833,7 +2833,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/custom-headers-contract', }, - events: [], featureId: 'customRequestHeaders', input: { content: 'hello world', @@ -2909,6 +2908,7 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'customRequestHeaders', + events: [], }, { behavior: 'resume-from-previous-upload', @@ -2916,60 +2916,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/resume-contract', }, - events: [ - { - fingerprint: 'contract-resume-fingerprint', - kind: 'fingerprint', - key: 'fingerprint:contract-resume-fingerprint', - }, - { - count: 1, - fingerprint: 'contract-resume-fingerprint', - kind: 'url-storage-find', - key: 'url-storage-find:contract-resume-fingerprint:1', - }, - { - fingerprint: 'contract-resume-fingerprint', - kind: 'fingerprint', - key: 'fingerprint:contract-resume-fingerprint', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 6, - kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', - }, - { - kind: 'url-storage-remove', - urlStorageKey: 'tus::contract-resume-fingerprint::1337', - key: 'url-storage-remove:tus::contract-resume-fingerprint::1337', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3052,23 +2998,32 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'resumeFromPreviousUpload', - }, - { - behavior: 'relative-location-resolution', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/files/relative-contract', - }, events: [ + { + fingerprint: 'contract-resume-fingerprint', + kind: 'fingerprint', + key: 'fingerprint:contract-resume-fingerprint', + }, + { + count: 1, + fingerprint: 'contract-resume-fingerprint', + kind: 'url-storage-find', + key: 'url-storage-find:contract-resume-fingerprint:1', + }, + { + fingerprint: 'contract-resume-fingerprint', + kind: 'fingerprint', + key: 'fingerprint:contract-resume-fingerprint', + }, { kind: 'upload-url-available', key: 'upload-url-available', }, { - bytesSent: 0, + bytesSent: 5, bytesTotal: 11, kind: 'progress', - key: 'progress:0:11', + key: 'progress:5:11', }, { bytesSent: 11, @@ -3079,9 +3034,14 @@ export const tusClientConformanceScenarios = [ { bytesAccepted: 11, bytesTotal: 11, - chunkSize: 11, + chunkSize: 6, kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', + key: 'chunk-complete:6:11:11', + }, + { + kind: 'url-storage-remove', + urlStorageKey: 'tus::contract-resume-fingerprint::1337', + key: 'url-storage-remove:tus::contract-resume-fingerprint::1337', }, { kind: 'success', @@ -3092,6 +3052,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'relative-location-resolution', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/files/relative-contract', + }, eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3163,19 +3130,29 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'relativeLocationResolution', - }, - { - behavior: 'array-buffer-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/array-buffer-contract', - }, events: [ { - inputKind: 'array-buffer', - kind: 'source-open', - size: 11, - key: 'source-open:array-buffer:11', + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + key: 'progress:0:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', }, { kind: 'success', @@ -3186,6 +3163,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'array-buffer-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/array-buffer-contract', + }, featureId: 'inputSources', input: { content: 'hello world', @@ -3252,19 +3236,12 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'arrayBufferInput', - }, - { - behavior: 'array-buffer-view-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', - }, events: [ { - inputKind: 'array-buffer-view', + inputKind: 'array-buffer', kind: 'source-open', size: 11, - key: 'source-open:array-buffer-view:11', + key: 'source-open:array-buffer:11', }, { kind: 'success', @@ -3275,6 +3252,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'array-buffer-view-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', + }, featureId: 'inputSources', input: { content: 'hello world', @@ -3341,19 +3325,12 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'arrayBufferViewInput', - }, - { - behavior: 'web-readable-stream-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/web-stream-contract', - }, events: [ { - inputKind: 'web-readable-stream', + inputKind: 'array-buffer-view', kind: 'source-open', - size: null, - key: 'source-open:web-readable-stream:null', + size: 11, + key: 'source-open:array-buffer-view:11', }, { kind: 'success', @@ -3364,6 +3341,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'web-readable-stream-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/web-stream-contract', + }, featureId: 'inputSources', input: { chunkSize: 100, @@ -3433,19 +3417,12 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'webReadableStreamInput', - }, - { - behavior: 'node-readable-stream-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/node-stream-contract', - }, events: [ { - inputKind: 'node-readable-stream', + inputKind: 'web-readable-stream', kind: 'source-open', size: null, - key: 'source-open:node-readable-stream:null', + key: 'source-open:web-readable-stream:null', }, { kind: 'success', @@ -3456,6 +3433,13 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + }, + { + behavior: 'node-readable-stream-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/node-stream-contract', + }, featureId: 'inputSources', input: { chunkSize: 100, @@ -3524,21 +3508,13 @@ export const tusClientConformanceScenarios = [ requestIndex: 1, }, ], - runtimes: ['node'], scenarioId: 'nodeReadableStreamInput', - }, - { - behavior: 'node-path-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/node-path-contract', - }, events: [ { - inputKind: 'node-path-reference', + inputKind: 'node-readable-stream', kind: 'source-open', - size: 11, - key: 'source-open:node-path-reference:11', + size: null, + key: 'source-open:node-readable-stream:null', }, { kind: 'success', @@ -3549,6 +3525,14 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + runtimes: ['node'], + }, + { + behavior: 'node-path-input', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/node-path-contract', + }, featureId: 'inputSources', input: { content: 'hello world', @@ -3614,38 +3598,13 @@ export const tusClientConformanceScenarios = [ requestIndex: 1, }, ], - runtimes: ['node'], - scenarioId: 'nodePathInput', - }, - { - behavior: 'deferred-length-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/deferred-contract', - }, - events: [ - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 11, - kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', + scenarioId: 'nodePathInput', + events: [ + { + inputKind: 'node-path-reference', + kind: 'source-open', + size: 11, + key: 'source-open:node-path-reference:11', }, { kind: 'success', @@ -3656,6 +3615,14 @@ export const tusClientConformanceScenarios = [ key: 'source-close', }, ], + runtimes: ['node'], + }, + { + behavior: 'deferred-length-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/deferred-contract', + }, eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3730,6 +3697,39 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'deferredLengthUpload', + events: [ + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + key: 'progress:0:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], }, { behavior: 'override-patch-method', @@ -3737,7 +3737,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/override-contract', }, - events: [], featureId: 'overridePatchMethod', input: { content: 'hello world', @@ -3804,6 +3803,7 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'overridePatchMethod', + events: [], }, { behavior: 'parallel-upload-concat', @@ -3811,34 +3811,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/parallel-final', }, - events: [ - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesAccepted: 5, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:5:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 6, - kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', - }, - ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -4015,19 +3987,40 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'parallelUploadConcat', + events: [ + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesAccepted: 5, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:5:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 6, + kind: 'chunk-complete', + key: 'chunk-complete:6:11:11', + }, + ], }, { behavior: 'parallel-upload-abort-cleanup', completion: { kind: 'aborted', }, - events: [ - { - kind: 'request-abort', - requestIndex: 3, - key: 'request-abort:3', - }, - ], execution: { serverRequestGates: [ { @@ -4228,6 +4221,13 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'parallelUploadAbortCleanup', + events: [ + { + kind: 'request-abort', + requestIndex: 3, + key: 'request-abort:3', + }, + ], }, { behavior: 'retry-patch-after-offset-recovery', @@ -4235,30 +4235,6 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/retry-contract', }, - events: [ - { - decision: true, - kind: 'should-retry', - retryAttempt: 0, - key: 'should-retry:0:true', - }, - { - delay: 0, - kind: 'retry-schedule', - key: 'retry-schedule:0', - }, - { - decision: true, - kind: 'should-retry', - retryAttempt: 0, - key: 'should-retry:0:true', - }, - { - delay: 0, - kind: 'retry-schedule', - key: 'retry-schedule:0', - }, - ], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -4431,33 +4407,37 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'retryPatchAfterOffsetRecovery', - }, - { - behavior: 'request-lifecycle-hooks', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/request-hooks-contract', - }, events: [ { - kind: 'before-request', - requestIndex: 0, - key: 'before-request:0', + decision: true, + kind: 'should-retry', + retryAttempt: 0, + key: 'should-retry:0:true', }, { - kind: 'after-response', - requestIndex: 0, - key: 'after-response:0', + delay: 0, + kind: 'retry-schedule', + key: 'retry-schedule:0', }, { - kind: 'success', - key: 'success', + decision: true, + kind: 'should-retry', + retryAttempt: 0, + key: 'should-retry:0:true', }, { - kind: 'source-close', - key: 'source-close', + delay: 0, + kind: 'retry-schedule', + key: 'retry-schedule:0', }, ], + }, + { + behavior: 'request-lifecycle-hooks', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/request-hooks-contract', + }, featureId: 'requestLifecycleHooks', input: { content: 'hello world', @@ -4495,19 +4475,32 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'requestLifecycleHooks', + events: [ + { + kind: 'before-request', + requestIndex: 0, + key: 'before-request:0', + }, + { + kind: 'after-response', + requestIndex: 0, + key: 'after-response:0', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], }, { behavior: 'abort-upload', completion: { kind: 'aborted', }, - events: [ - { - kind: 'request-abort', - requestIndex: 0, - key: 'request-abort:0', - }, - ], execution: { onRequestStart: [ { @@ -4548,6 +4541,13 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'abortUpload', + events: [ + { + kind: 'request-abort', + requestIndex: 0, + key: 'request-abort:0', + }, + ], }, { behavior: 'abort-upload-after-stored-url', @@ -4555,13 +4555,6 @@ export const tusClientConformanceScenarios = [ kind: 'aborted', uploadUrl: 'https://tus.io/uploads/abort-terminate-contract', }, - events: [ - { - kind: 'request-abort', - requestIndex: 1, - key: 'request-abort:1', - }, - ], execution: { onRequestStart: [ { @@ -4664,6 +4657,13 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'abortUploadAfterStoredUrl', + events: [ + { + kind: 'request-abort', + requestIndex: 1, + key: 'request-abort:1', + }, + ], }, { behavior: 'terminate-with-retry', @@ -4671,19 +4671,6 @@ export const tusClientConformanceScenarios = [ kind: 'terminated', uploadUrl: 'https://tus.io/uploads/terminate-contract', }, - events: [ - { - decision: true, - kind: 'should-retry', - retryAttempt: 0, - key: 'should-retry:0:true', - }, - { - delay: 0, - kind: 'retry-schedule', - key: 'retry-schedule:0', - }, - ], execution: { onChunkComplete: [ { @@ -4804,6 +4791,19 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'terminateWithRetry', + events: [ + { + decision: true, + kind: 'should-retry', + retryAttempt: 0, + key: 'should-retry:0:true', + }, + { + delay: 0, + kind: 'retry-schedule', + key: 'retry-schedule:0', + }, + ], }, ] From d1cf182349c702e3898b380cb2712174f2f7c4c8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:10:01 +0200 Subject: [PATCH 101/155] Regenerate TUS protocol response facts --- lib/protocol_generated.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index c8428fdf6..e60e66ba8 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -121,6 +121,14 @@ export const TUS_PROTOCOL_REQUEST_HEADERS: Record }, } +export const TUS_PROTOCOL_RESPONSE_HEADERS: Record> = { + 'tus-v1': { + 'Tus-Resumable': '1.0.0', + }, + 'ietf-draft-03': {}, + 'ietf-draft-05': {}, +} + export const TUS_PROTOCOL_UPLOAD_BODY_CONTENT_TYPES: Record = { 'ietf-draft-05': 'application/partial-upload', 'tus-v1': 'application/offset+octet-stream', @@ -2754,6 +2762,10 @@ export function tusRequestHeadersForProtocol(protocol: string): Record { + return { ...(TUS_PROTOCOL_RESPONSE_HEADERS[protocol] ?? {}) } +} + export function tusRequestPlanForOperation({ headers = {}, operationId, From befe02048a8c5bdb8eff2ca88b20c2b648c095aa Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:56:12 +0200 Subject: [PATCH 102/155] Use generated TUS default headers in specs --- test/spec/helpers/utils.js | 32 +++++++++++-- test/spec/test-binary-data.js | 8 ++-- test/spec/test-browser-specific.js | 17 ++++--- test/spec/test-common.js | 45 +++++++++++-------- test/spec/test-generated-protocol-contract.js | 24 +++++----- test/spec/test-node-specific.js | 20 ++++----- test/spec/test-parallel-uploads.js | 44 ++++++++++-------- test/spec/test-web-stream.js | 8 ++-- 8 files changed, 118 insertions(+), 80 deletions(-) diff --git a/test/spec/helpers/utils.js b/test/spec/helpers/utils.js index 192f4b3a0..777a60010 100644 --- a/test/spec/helpers/utils.js +++ b/test/spec/helpers/utils.js @@ -1,3 +1,9 @@ +import { + TUS_DEFAULT_CLIENT_PROTOCOL, + tusRequestHeadersForProtocol, + tusResponseHeadersForProtocol, +} from '../../../lib.esm/protocol_generated.js' + /** * Helper function to create a Blob from a string. */ @@ -5,6 +11,26 @@ export function getBlob(str) { return new Blob(str.split('')) } +export function defaultProtocolRequestHeaders() { + return tusRequestHeadersForProtocol(TUS_DEFAULT_CLIENT_PROTOCOL) +} + +export function defaultProtocolResponseHeaders() { + return tusResponseHeadersForProtocol(TUS_DEFAULT_CLIENT_PROTOCOL) +} + +export function expectTusDefaultRequestHeaders(requestHeaders) { + for (const [name, value] of Object.entries(defaultProtocolRequestHeaders())) { + expect(requestHeaders[name]).toBe(value) + } +} + +export function expectTusDefaultResponseHeaders(responseHeaders) { + for (const [name, value] of Object.entries(defaultProtocolResponseHeaders())) { + expect(responseHeaders.get(name)).toBe(value) + } +} + /** * Helper function to create a Blob of a specific size filled with repeated content. * Works in both Node.js and browser environments. @@ -112,13 +138,11 @@ export function validateUploadContent(upload, originalBlob) { export function validateUploadMetadata(upload, expectedSize) { return fetch(upload.url, { method: 'HEAD', - headers: { - 'Tus-Resumable': '1.0.0', - }, + headers: defaultProtocolRequestHeaders(), }) .then((res) => { expect(res.status).toBe(200) - expect(res.headers.get('tus-resumable')).toBe('1.0.0') + expectTusDefaultResponseHeaders(res.headers) expect(res.headers.get('upload-offset')).toBe(String(expectedSize)) expect(res.headers.get('upload-length')).toBe(String(expectedSize)) diff --git a/test/spec/test-binary-data.js b/test/spec/test-binary-data.js index 0c6cfcc5a..4f8b3e22e 100644 --- a/test/spec/test-binary-data.js +++ b/test/spec/test-binary-data.js @@ -1,5 +1,5 @@ import { Upload } from 'tus-js-client' -import { TestHttpStack, waitableFunction } from './helpers/utils.js' +import { expectTusDefaultRequestHeaders, TestHttpStack, waitableFunction } from './helpers/utils.js' describe('tus', () => { describe('#Upload', () => { @@ -52,7 +52,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('13') req.respondWith({ @@ -65,7 +65,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(7) @@ -80,7 +80,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('7') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(6) diff --git a/test/spec/test-browser-specific.js b/test/spec/test-browser-specific.js index a91779678..7b00aceeb 100644 --- a/test/spec/test-browser-specific.js +++ b/test/spec/test-browser-specific.js @@ -1,7 +1,12 @@ import { defaultOptions, Upload } from 'tus-js-client' import { tusClientUrlStorageConformanceScenarios } from './generated-protocol-contract.js' import { assertUrlStorage, findUrlStorageScenario } from './helpers/assertUrlStorage.js' -import { TestHttpStack, wait, waitableFunction } from './helpers/utils.js' +import { + expectTusDefaultRequestHeaders, + TestHttpStack, + wait, + waitableFunction, +} from './helpers/utils.js' describe('tus', () => { beforeEach(() => { @@ -46,7 +51,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -59,7 +64,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('3') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11 - 3) @@ -173,7 +178,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/storedUrl') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -196,7 +201,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/storedUrl') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('3') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11 - 3) @@ -321,7 +326,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11) diff --git a/test/spec/test-common.js b/test/spec/test-common.js index 26927ff49..91a35e4d8 100644 --- a/test/spec/test-common.js +++ b/test/spec/test-common.js @@ -1,5 +1,12 @@ import { isSupported, Upload } from 'tus-js-client' -import { getBlob, TestHttpStack, TestResponse, wait, waitableFunction } from './helpers/utils.js' +import { + expectTusDefaultRequestHeaders, + getBlob, + TestHttpStack, + TestResponse, + wait, + waitableFunction, +} from './helpers/utils.js' // Uncomment to enable debug log from tus-js-client // tus.enableDebugLog(); @@ -54,7 +61,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('11') expect(req.requestHeaders['Upload-Metadata']).toBe( 'foo aGVsbG8=,bar d29ybGQ=,nonlatin c8WCb8WEY2U=,number MTAw', @@ -74,7 +81,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11) @@ -107,7 +114,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 404, @@ -116,7 +123,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('11') // The upload URL should be cleared when tus-js.client tries to create a new upload. @@ -144,7 +151,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('11') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11) @@ -188,7 +195,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('11') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(6) @@ -211,7 +218,7 @@ describe('tus', () => { expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('6') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(5) @@ -350,7 +357,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 404, @@ -418,7 +425,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('11') req.respondWith({ @@ -431,7 +438,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(7) @@ -446,7 +453,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('7') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(4) @@ -516,7 +523,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('0') req.respondWith({ @@ -548,7 +555,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -587,7 +594,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/files/upload') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -603,7 +610,7 @@ describe('tus', () => { expect(req.url).toBe('http://tus.io/files/upload') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('3') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11 - 3) @@ -702,7 +709,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/files/upload') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -715,7 +722,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/files/upload') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('3') expect(req.requestHeaders['X-HTTP-Method-Override']).toBe('PATCH') @@ -805,7 +812,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index d4ead4bd8..caca43603 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -6,9 +6,14 @@ import { tusManagedUpload, tusManagedUploadProofCases, tusProtocolOperations, - tusWireVersions, } from './generated-protocol-contract.js' -import { getBlob, TestHttpStack, wait, waitableFunction } from './helpers/utils.js' +import { + defaultProtocolResponseHeaders, + getBlob, + TestHttpStack, + wait, + waitableFunction, +} from './helpers/utils.js' function getProtocolOperation(operationId) { const operation = tusProtocolOperations.find((candidate) => candidate.operationId === operationId) @@ -50,15 +55,6 @@ function getManagedUploadScenario(scenarioId) { return scenario } -function getDefaultWireVersion() { - const versions = tusWireVersions.filter((candidate) => candidate.default) - if (versions.length !== 1) { - throw new Error('Generated TUS protocol contract must have exactly one default wire version') - } - - return versions[0].value -} - function getGeneratedConformanceRuntime() { if (typeof window !== 'undefined' && window.document) { return 'browser' @@ -111,7 +107,7 @@ function getOperationResponse(operation, statusCode) { function responseHeadersFor(response, overrides = {}) { const headers = {} const variant = response.headerVariants[0] - const defaultWireVersion = getDefaultWireVersion() + const defaultResponseHeaders = defaultProtocolResponseHeaders() for (const field of variant?.fields ?? []) { if (!field.required) continue if (overrides[field.displayName] != null) { @@ -119,8 +115,8 @@ function responseHeadersFor(response, overrides = {}) { continue } - if (field.displayName === 'Tus-Resumable') { - headers[field.displayName] = defaultWireVersion + if (defaultResponseHeaders[field.displayName] != null) { + headers[field.displayName] = defaultResponseHeaders[field.displayName] continue } diff --git a/test/spec/test-node-specific.js b/test/spec/test-node-specific.js index 1c6c4c87b..a63d744c0 100644 --- a/test/spec/test-node-specific.js +++ b/test/spec/test-node-specific.js @@ -13,7 +13,7 @@ import { NodeHttpStack } from 'tus-js-client/node/NodeHttpStack' import { NodeStreamFileSource } from 'tus-js-client/node/sources/NodeStreamFileSource' import { tusClientUrlStorageConformanceScenarios } from './generated-protocol-contract.js' import { assertUrlStorage, findUrlStorageScenario } from './helpers/assertUrlStorage.js' -import { TestHttpStack, waitableFunction } from './helpers/utils.js' +import { expectTusDefaultRequestHeaders, TestHttpStack, waitableFunction } from './helpers/utils.js' describe('tus', () => { describe('#canStoreURLs', () => { @@ -127,7 +127,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -189,7 +189,7 @@ describe('tus', () => { const [firstPartCreateRequest, secondPartCreateRequest] = createRequests expect(firstPartCreateRequest.url).toBe('https://tus.io/uploads') expect(firstPartCreateRequest.method).toBe('POST') - expect(firstPartCreateRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(firstPartCreateRequest.requestHeaders) expect(firstPartCreateRequest.requestHeaders['Upload-Length']).toBe('5') expect(firstPartCreateRequest.requestHeaders['Upload-Concat']).toBe('partial') @@ -202,7 +202,7 @@ describe('tus', () => { expect(secondPartCreateRequest.url).toBe('https://tus.io/uploads') expect(secondPartCreateRequest.method).toBe('POST') - expect(secondPartCreateRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(secondPartCreateRequest.requestHeaders) expect(secondPartCreateRequest.requestHeaders['Upload-Length']).toBe('6') expect(secondPartCreateRequest.requestHeaders['Upload-Concat']).toBe('partial') @@ -220,7 +220,7 @@ describe('tus', () => { const [firstPartPatchRequest, secondPartPatchRequest] = patchRequests expect(firstPartPatchRequest.url).toBe('https://tus.io/uploads/upload1') expect(firstPartPatchRequest.method).toBe('PATCH') - expect(firstPartPatchRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(firstPartPatchRequest.requestHeaders) expect(firstPartPatchRequest.requestHeaders['Upload-Offset']).toBe('0') expect(firstPartPatchRequest.requestHeaders['Content-Type']).toBe( 'application/offset+octet-stream', @@ -236,7 +236,7 @@ describe('tus', () => { expect(secondPartPatchRequest.url).toBe('https://tus.io/uploads/upload2') expect(secondPartPatchRequest.method).toBe('PATCH') - expect(secondPartPatchRequest.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(secondPartPatchRequest.requestHeaders) expect(secondPartPatchRequest.requestHeaders['Upload-Offset']).toBe('0') expect(secondPartPatchRequest.requestHeaders['Content-Type']).toBe( 'application/offset+octet-stream', @@ -253,7 +253,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBeUndefined() expect(req.requestHeaders['Upload-Concat']).toBe( 'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2', @@ -301,7 +301,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -375,7 +375,7 @@ describe('tus', () => { let req = await options.httpStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -390,7 +390,7 @@ describe('tus', () => { req = await options.httpStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/resuming') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('3') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(11 - 3) diff --git a/test/spec/test-parallel-uploads.js b/test/spec/test-parallel-uploads.js index 101180348..840ad6f70 100644 --- a/test/spec/test-parallel-uploads.js +++ b/test/spec/test-parallel-uploads.js @@ -1,5 +1,11 @@ import { Upload } from 'tus-js-client' -import { getBlob, TestHttpStack, wait, waitableFunction } from './helpers/utils.js' +import { + expectTusDefaultRequestHeaders, + getBlob, + TestHttpStack, + wait, + waitableFunction, +} from './helpers/utils.js' describe('tus', () => { describe('parallel uploading', () => { @@ -92,7 +98,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('5') expect(req.requestHeaders['Upload-Concat']).toBe('partial') expect(req.requestHeaders['Upload-Metadata']).toBe('test d29ybGQ=') // world @@ -108,7 +114,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('6') expect(req.requestHeaders['Upload-Concat']).toBe('partial') expect(req.requestHeaders['Upload-Metadata']).toBe('test d29ybGQ=') // world @@ -128,7 +134,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads/upload1') expect(req.method).toBe('PATCH') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(5) @@ -144,7 +150,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads/upload2') expect(req.method).toBe('PATCH') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(6) @@ -170,7 +176,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads/upload2') expect(req.method).toBe('PATCH') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(6) @@ -186,7 +192,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') expect(req.requestHeaders.Custom).toBe('blargh') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBeUndefined() expect(req.requestHeaders['Upload-Concat']).toBe( 'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2', @@ -230,7 +236,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('1') expect(req.requestHeaders['Upload-Concat']).toBe('partial') @@ -244,7 +250,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('10') expect(req.requestHeaders['Upload-Concat']).toBe('partial') @@ -259,7 +265,7 @@ describe('tus', () => { expect(req.url).toBe('https://tus.io/uploads/upload1') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(1) @@ -274,7 +280,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads/upload2') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(10) @@ -290,7 +296,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads/upload2') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('0') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req.bodySize).toBe(10) @@ -305,7 +311,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBeUndefined() expect(req.requestHeaders['Upload-Concat']).toBe( 'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2', @@ -339,7 +345,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('5') req.respondWith({ @@ -464,7 +470,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('5') expect(req.requestHeaders['Upload-Concat']).toBe('partial') expect(req.requestHeaders['Upload-Metadata']).toBeUndefined() @@ -479,7 +485,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBe('6') expect(req.requestHeaders['Upload-Concat']).toBe('partial') expect(req.requestHeaders['Upload-Metadata']).toBeUndefined() @@ -494,7 +500,7 @@ describe('tus', () => { const req1 = await testStack.nextRequest() expect(req1.url).toBe('https://tus.io/uploads/upload1') expect(req1.method).toBe('PATCH') - expect(req1.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req1.requestHeaders) expect(req1.requestHeaders['Upload-Offset']).toBe('0') expect(req1.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req1.bodySize).toBe(5) @@ -502,7 +508,7 @@ describe('tus', () => { const req2 = await testStack.nextRequest() expect(req2.url).toBe('https://tus.io/uploads/upload2') expect(req2.method).toBe('PATCH') - expect(req2.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req2.requestHeaders) expect(req2.requestHeaders['Upload-Offset']).toBe('0') expect(req2.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') expect(req2.bodySize).toBe(6) @@ -559,7 +565,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('https://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Length']).toBeUndefined() expect(req.requestHeaders['Upload-Concat']).toBe( 'final;https://tus.io/uploads/upload1 https://tus.io/uploads/upload2', diff --git a/test/spec/test-web-stream.js b/test/spec/test-web-stream.js index 0a4c7a8e6..74f21912f 100644 --- a/test/spec/test-web-stream.js +++ b/test/spec/test-web-stream.js @@ -1,5 +1,5 @@ import { Upload } from 'tus-js-client' -import { TestHttpStack, waitableFunction } from './helpers/utils.js' +import { expectTusDefaultRequestHeaders, TestHttpStack, waitableFunction } from './helpers/utils.js' describe('tus', () => { describe('#Upload', () => { @@ -136,7 +136,7 @@ describe('tus', () => { req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/blargh') expect(req.method).toBe('PATCH') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) expect(req.requestHeaders['Upload-Offset']).toBe('6') expect(req.requestHeaders['Upload-Length']).toBe('11') expect(req.requestHeaders['Content-Type']).toBe('application/offset+octet-stream') @@ -403,7 +403,7 @@ describe('tus', () => { const req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads') expect(req.method).toBe('POST') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) req.respondWith({ status: 204, @@ -437,7 +437,7 @@ describe('tus', () => { let req = await testStack.nextRequest() expect(req.url).toBe('http://tus.io/uploads/fileid') expect(req.method).toBe('HEAD') - expect(req.requestHeaders['Tus-Resumable']).toBe('1.0.0') + expectTusDefaultRequestHeaders(req.requestHeaders) // Respond with a non zero offset to test that the stream that is created // for the reader returns the correct data and ignores the data in the stream From 05fda15a9cfdef7722dbb7b4df2eb10ce2ea2e95 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 16:48:46 +0200 Subject: [PATCH 103/155] Resolve TUS operation IDs by role --- lib/protocol_generated.ts | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index e60e66ba8..b879481b5 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -45,6 +45,15 @@ export const TUS_OPERATION_METHOD_BY_ID: Record = { downloadTusUpload: 'GET', } +export const TUS_OPERATION_ID_BY_ROLE: Record = { + 'capability-discovery': 'discoverTusCapabilities', + creation: 'createTusUpload', + 'offset-discovery': 'getTusUploadOffset', + 'upload-chunk': 'patchTusUpload', + termination: 'terminateTusUpload', + download: 'downloadTusUpload', +} + export const TUS_OPERATION_IDS = { DISCOVER_TUS_CAPABILITIES: 'discoverTusCapabilities', CREATE_TUS_UPLOAD: 'createTusUpload', @@ -2736,7 +2745,7 @@ export function tusShouldRetryStatus(status: number): boolean { } export function tusPlanTerminateResponse({ status }: { status: number }): TusTerminateResponsePlan { - if (tusExpectedResponseStatusForOperation(TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, status)) { + if (tusExpectedResponseStatusForOperation(tusOperationIdForRole('termination'), status)) { return { action: 'complete' } } @@ -2754,6 +2763,15 @@ export function tusExpectedResponseStatusForOperation( return TUS_OPERATION_RESPONSE_STATUS_CODES[operationId]?.includes(status) ?? false } +export function tusOperationIdForRole(role: string): string { + const operationId = TUS_OPERATION_ID_BY_ROLE[role] + if (operationId == null) { + throw new Error(`Unknown TUS operation role: ${role}`) + } + + return operationId +} + export function tusRequiresKnownUploadLengthOnOffsetResponse(protocol: string): boolean { return TUS_PROTOCOLS_REQUIRING_KNOWN_UPLOAD_LENGTH_ON_OFFSET_RESPONSE.includes(protocol) } @@ -2962,7 +2980,7 @@ export function tusCreateUploadRequestPlan({ ? {} : tusUploadCompleteHeaders({ done: uploadComplete, protocol })), }, - operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, + operationId: tusOperationIdForRole('creation'), protocol, url: endpoint, }) @@ -2984,7 +3002,7 @@ export function tusFinalUploadRequestPlan({ metadata, uploadUrls, }), - operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, + operationId: tusOperationIdForRole('creation'), protocol, url: endpoint, }) @@ -2998,7 +3016,7 @@ export function tusGetUploadOffsetRequestPlan({ uploadUrl: string }): TusRequestPlan { return tusRequestPlanForOperation({ - operationId: TUS_OPERATION_IDS.GET_TUS_UPLOAD_OFFSET, + operationId: tusOperationIdForRole('offset-discovery'), protocol, url: uploadUrl, }) @@ -3015,15 +3033,16 @@ export function tusPatchUploadRequestPlan({ protocol: string uploadUrl: string }): TusRequestPlan { + const operationId = tusOperationIdForRole('upload-chunk') const methodOverride = overridePatchMethod - ? tusMethodOverrideForOperation(TUS_OPERATION_IDS.PATCH_TUS_UPLOAD) + ? tusMethodOverrideForOperation(operationId) : undefined const plan = tusRequestPlanForOperation({ headers: { ...(methodOverride?.headers ?? {}), ...tusPatchUploadHeaders({ offset }), }, - operationId: TUS_OPERATION_IDS.PATCH_TUS_UPLOAD, + operationId, protocol, url: uploadUrl, }) @@ -3042,7 +3061,7 @@ export function tusTerminateUploadRequestPlan({ uploadUrl: string }): TusRequestPlan { return tusRequestPlanForOperation({ - operationId: TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, + operationId: tusOperationIdForRole('termination'), protocol, url: uploadUrl, }) From 1a5d7a3cb052d8d5a37b523476aaae6d4a61ff84 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 00:04:46 +0200 Subject: [PATCH 104/155] Update generated TUS concatenation facts --- lib/protocol_generated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index b879481b5..6be9abbc1 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -160,6 +160,7 @@ export const TUS_PROTOCOL_UPLOAD_COMPLETE_HEADERS: Record< } export const TUS_CONCATENATION = { + extensionName: 'concatenation', finalPrefix: 'final;', headerName: 'Upload-Concat', partialValue: 'partial', From a3a52c09a5dca9bfc0a8d56f22e42be4f4885ba9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 02:09:39 +0200 Subject: [PATCH 105/155] Add generated TUS request ID proof --- test/spec/generated-protocol-contract.js | 145 +++++++++++++++--- test/spec/test-generated-protocol-contract.js | 27 ++++ 2 files changed, 147 insertions(+), 25 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index e7628e0bb..b32f46a77 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -507,6 +507,34 @@ export const tusClientFeatures = [ operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['apply-custom-request-headers'], }, + { + conformance: { + scenarioIds: ['requestIdHeaders'], + status: 'covered-by-generated-scenario', + }, + description: 'Add generated request IDs after protocol and custom request headers.', + featureId: 'requestIdHeaders', + flow: [ + { + kind: 'primitive', + primitive: 'add-request-id-header', + summary: + 'Generate a request ID and apply it after custom request headers so it is authoritative.', + }, + { + kind: 'operation', + operationId: 'createTusUpload', + summary: 'Create uploads with a generated request ID.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Upload bytes with a generated request ID.', + }, + ], + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['add-request-id-header', 'apply-custom-request-headers'], + }, { conformance: { scenarioIds: ['overridePatchMethod'], @@ -2003,7 +2031,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, headersSpecified: true, @@ -2085,7 +2112,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, headersSpecified: true, @@ -2113,7 +2139,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, headersSpecified: true, @@ -2140,7 +2165,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, headersSpecified: true, @@ -2266,10 +2290,10 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: 'exact', headers: { - 'Content-Type': 'application/partial-upload', + 'Upload-Length': '11', 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', 'Upload-Draft-Interop-Version': '6', - 'Upload-Length': '11', }, headersSpecified: true, method: null, @@ -2803,7 +2827,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -2838,6 +2861,7 @@ export const tusClientConformanceScenarios = [ content: 'hello world', endpointUrl: 'https://tus.io/uploads', headers: { + 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -2857,6 +2881,7 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Length': '11', + 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -2884,8 +2909,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -2910,6 +2935,87 @@ export const tusClientConformanceScenarios = [ scenarioId: 'customRequestHeaders', events: [], }, + { + behavior: 'request-id-headers', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/request-id-contract', + }, + featureId: 'requestIdHeaders', + input: { + addRequestId: true, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + generatedRequestId: '00000000-0000-4000-8000-000000000000', + headers: { + 'X-Request-ID': 'custom-request-id', + }, + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['add-request-id-header', 'apply-custom-request-headers'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/request-id-contract', + }, + headersSpecified: true, + statusCode: 201, + }, + role: 'create-upload', + uploadUrl: null, + url: 'endpoint', + requestIndex: 0, + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Offset': '0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: null, + headers: { + 'Upload-Offset': '11', + }, + headersSpecified: true, + statusCode: 204, + }, + role: 'upload-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 1, + }, + ], + scenarioId: 'requestIdHeaders', + events: [], + }, { behavior: 'resume-from-previous-upload', completion: { @@ -3780,12 +3886,10 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '3', - 'X-HTTP-Method-Override': 'PATCH', }, headersSpecified: true, - method: 'POST', + method: null, operationId: 'patchTusUpload', response: { body: null, @@ -3858,7 +3962,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'Upload-Metadata': 'test d29ybGQ=', }, headersSpecified: true, method: null, @@ -3886,7 +3989,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'Upload-Metadata': 'test d29ybGQ=', }, headersSpecified: true, method: null, @@ -3966,7 +4068,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', - 'Upload-Metadata': 'foo aGVsbG8=', }, headersSpecified: true, method: null, @@ -4068,7 +4169,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'Upload-Metadata': 'test d29ybGQ=', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, @@ -4098,7 +4198,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'Upload-Metadata': 'test d29ybGQ=', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, @@ -4126,14 +4225,12 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, - method: 'POST', + method: null, operationId: 'patchTusUpload', response: { body: null, @@ -4154,14 +4251,12 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, - method: 'POST', + method: null, operationId: 'patchTusUpload', response: null, role: 'upload-partial-chunk', @@ -4590,6 +4685,8 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Length': '11', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', }, headersSpecified: true, method: null, @@ -4615,14 +4712,12 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, headersSpecified: true, - method: 'POST', + method: null, operationId: 'patchTusUpload', response: null, role: 'abort-upload-chunk', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index caca43603..fda9dadb7 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -202,6 +202,25 @@ async function createScenarioInput(input) { throw new Error(`Unsupported generated TUS scenario input kind: ${input.kind}`) } +function installGeneratedRequestIdRandom(scenario) { + if (!scenario.input.addRequestId) { + return () => {} + } + + const expectedZeroUuid = '00000000-0000-4000-8000-000000000000' + if (scenario.input.generatedRequestId !== expectedZeroUuid) { + throw new Error( + `Generated scenario ${scenario.scenarioId} has unsupported generatedRequestId ${scenario.input.generatedRequestId}`, + ) + } + + const originalRandom = Math.random + Math.random = () => 0 + return () => { + Math.random = originalRandom + } +} + function storedUploadKey(storedUpload) { return storedUpload.urlStorageKey ?? storedUpload.fingerprint } @@ -493,6 +512,7 @@ async function startScenarioUpload(scenario, testStack) { let retryDecisionIndex = 0 const observedEvents = [] const restoreRetryTimerRecorder = installRetryTimerRecorder(scenario, observedEvents) + const restoreRequestIdRandom = installGeneratedRequestIdRandom(scenario) const onError = waitableFunction('onError') const onSuccess = waitableFunction('onSuccess') const options = { @@ -586,6 +606,10 @@ async function startScenarioUpload(scenario, testStack) { options.headers = scenario.input.headers } + if (scenario.input.addRequestId != null) { + options.addRequestId = scenario.input.addRequestId + } + if (scenario.input.overridePatchMethod != null) { options.overridePatchMethod = scenario.input.overridePatchMethod } @@ -701,6 +725,7 @@ async function startScenarioUpload(scenario, testStack) { observedEvents, onError, onSuccess, + restoreRequestIdRandom, restoreRetryTimerRecorder, terminatePromise: () => terminatePromise, upload, @@ -733,6 +758,7 @@ async function runGeneratedConformanceScenario(scenario) { onError, onSuccess, restoreRetryTimerRecorder, + restoreRequestIdRandom, terminatePromise, upload, } = await startScenarioUpload(scenario, testStack) @@ -789,6 +815,7 @@ async function runGeneratedConformanceScenario(scenario) { expect(onError).not.toHaveBeenCalled() expectScenarioEvents(scenario, observedEvents) } finally { + restoreRequestIdRandom() restoreRetryTimerRecorder() } } From e91f4fd23a218cb95fb37cec2c1f95846bf1edbc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:57:59 +0200 Subject: [PATCH 106/155] Use effective TUS contract fixtures --- test/spec/generated-protocol-contract.js | 1404 ++++++++++++++--- test/spec/test-generated-protocol-contract.js | 13 +- 2 files changed, 1213 insertions(+), 204 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b32f46a77..be2d14526 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -410,10 +410,11 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: ['deferredLengthUpload'], + scenarioIds: ['deferredLengthUpload', 'deferredLengthChunkedUpload'], status: 'covered-by-generated-scenario', }, - description: 'Create an upload without a known length and declare the length on final PATCH.', + description: + 'Create an upload without a known length and declare the length on the final upload request.', featureId: 'deferredLengthUpload', flow: [ { @@ -424,16 +425,16 @@ export const tusClientFeatures = [ { kind: 'primitive', primitive: 'defer-upload-length', - summary: 'Track the source until the final chunk reveals the total size.', + summary: 'Track the source until the final upload request reveals the total size.', }, { kind: 'operation', operationId: 'patchTusUpload', - summary: 'Declare Upload-Length on the final chunk request.', + summary: 'Declare Upload-Length on the final upload request.', }, ], operationIds: ['createTusUpload', 'patchTusUpload'], - primitives: ['defer-upload-length', 'emit-progress'], + primitives: ['defer-upload-length', 'emit-chunk-complete', 'emit-progress'], }, { conformance: { @@ -826,7 +827,11 @@ export const tusClientFeatures = [ }, { conformance: { - scenarioIds: ['ietfDraft05CreationWithUpload', 'ietfDraft03ResumeWithoutKnownLength'], + scenarioIds: [ + 'ietfDraft05CreationWithUpload', + 'ietfDraft05ChunkedUploadComplete', + 'ietfDraft03ResumeWithoutKnownLength', + ], status: 'covered-by-generated-scenario', }, description: 'Select between tus v1 and supported IETF draft client protocol modes.', @@ -1907,10 +1912,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -1921,11 +1924,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/generated-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -1947,11 +1960,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'singleUploadLifecycle', @@ -2030,10 +2053,8 @@ export const tusClientConformanceScenarios = [ bodySize: 11, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2045,11 +2066,23 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/creation-with-upload-contract', + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'creationWithUpload', @@ -2111,10 +2144,8 @@ export const tusClientConformanceScenarios = [ bodySize: 5, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2126,11 +2157,23 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/creation-with-upload-partial-contract', + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -2152,11 +2195,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '10', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: [], @@ -2178,11 +2231,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-final-chunk', uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', url: 'upload', requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'creationWithUploadPartialChunk', @@ -2289,13 +2352,8 @@ export const tusClientConformanceScenarios = [ bodySize: 11, errorMessage: null, headerMode: 'exact', - headers: { - 'Upload-Length': '11', - 'Upload-Complete': '?1', - 'Content-Type': 'application/partial-upload', - 'Upload-Draft-Interop-Version': '6', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2307,11 +2365,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/ietf-draft-05-contract', + 'Upload-Offset': '11', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '6', + 'Upload-Length': '11', + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'ietfDraft05CreationWithUpload', @@ -2346,7 +2415,7 @@ export const tusClientConformanceScenarios = [ behavior: 'upload-body-headers', completion: { kind: 'success', - uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, eventPolicy: { matching: 'exact-except-extra-progress', @@ -2355,12 +2424,12 @@ export const tusClientConformanceScenarios = [ }, featureId: 'protocolVersionSelection', input: { - chunkSize: 6, + chunkSize: 5, content: 'hello world', endpointUrl: 'https://tus.io/uploads', kind: 'blob', - protocol: 'ietf-draft-03', - uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + protocol: 'ietf-draft-05', + uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['select-client-protocol'], @@ -2371,37 +2440,114 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: 'exact', - headers: { - 'Upload-Draft-Interop-Version': '5', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'getTusUploadOffset', response: { body: null, headerMode: 'exact', headers: { - 'Upload-Offset': '5', + 'Upload-Length': '11', + 'Upload-Offset': '0', }, headersSpecified: true, statusCode: 200, + effectiveHeaders: { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 0, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '6', + }, + effectiveMethod: 'HEAD', }, { - absentHeaders: ['Content-Type', 'Tus-Resumable'], + absentHeaders: ['Tus-Resumable'], abort: false, - bodySize: 6, + bodySize: 5, errorMessage: null, headerMode: 'exact', headers: { - 'Upload-Complete': '?1', - 'Upload-Draft-Interop-Version': '5', + 'Upload-Offset': '0', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '5', + }, + headersSpecified: true, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '5', + }, + }, + role: 'upload-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 1, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '6', + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', + }, + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: 5, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '5', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '10', + }, + headersSpecified: true, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '10', + }, + }, + role: 'upload-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 2, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '6', + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', 'Upload-Offset': '5', }, + effectiveMethod: 'PATCH', + }, + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: 1, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '10', + }, headersSpecified: true, method: null, operationId: 'patchTusUpload', @@ -2413,25 +2559,73 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + }, }, - role: null, + role: 'upload-final-chunk', uploadUrl: null, url: 'upload', - requestIndex: 1, + requestIndex: 3, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '6', + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '10', + }, + effectiveMethod: 'PATCH', }, ], - scenarioId: 'ietfDraft03ResumeWithoutKnownLength', + scenarioId: 'ietfDraft05ChunkedUploadComplete', events: [ { kind: 'upload-url-available', key: 'upload-url-available', }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + key: 'progress:0:11', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesAccepted: 5, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:5:11', + }, { bytesSent: 5, bytesTotal: 11, kind: 'progress', key: 'progress:5:11', }, + { + bytesSent: 10, + bytesTotal: 11, + kind: 'progress', + key: 'progress:10:11', + }, + { + bytesAccepted: 10, + bytesTotal: 11, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:10:11', + }, + { + bytesSent: 10, + bytesTotal: 11, + kind: 'progress', + key: 'progress:10:11', + }, { bytesSent: 11, bytesTotal: 11, @@ -2441,9 +2635,9 @@ export const tusClientConformanceScenarios = [ { bytesAccepted: 11, bytesTotal: 11, - chunkSize: 6, + chunkSize: 1, kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', + key: 'chunk-complete:1:11:11', }, { kind: 'success', @@ -2456,50 +2650,175 @@ export const tusClientConformanceScenarios = [ ], }, { - behavior: 'start-option-validation', + behavior: 'upload-body-headers', completion: { - kind: 'error', - message: 'tus: no file or stream to upload provided', - reason: 'missingInput', - }, - featureId: 'startOptionValidation', - input: { - content: '', - endpointUrl: 'https://tus.io/uploads', - kind: 'none', + kind: 'success', + uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, - operationIds: [], - primitives: ['validate-start-options'], - requests: [], - scenarioId: 'startValidationMissingInput', - events: [], - }, - { - behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: neither an endpoint or an upload URL is provided', - reason: 'missingEndpointOrUploadUrl', + eventPolicy: { + matching: 'exact-except-extra-progress', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', }, - featureId: 'startOptionValidation', + featureId: 'protocolVersionSelection', input: { + chunkSize: 6, content: 'hello world', + endpointUrl: 'https://tus.io/uploads', kind: 'blob', + protocol: 'ietf-draft-03', + uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, - operationIds: [], - primitives: ['validate-start-options'], - requests: [], - scenarioId: 'startValidationMissingEndpointOrUploadUrl', - events: [], - }, - { - behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: unsupported protocol tus-v9', - reason: 'unsupportedProtocol', - }, - featureId: 'startOptionValidation', + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['select-client-protocol'], + requests: [ + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: 'exact', + headers: {}, + headersSpecified: false, + method: null, + operationId: 'getTusUploadOffset', + response: { + body: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '5', + }, + headersSpecified: true, + statusCode: 200, + effectiveHeaders: { + 'Upload-Offset': '5', + }, + }, + role: null, + uploadUrl: null, + url: 'upload', + requestIndex: 0, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '5', + }, + effectiveMethod: 'HEAD', + }, + { + absentHeaders: ['Content-Type', 'Tus-Resumable'], + abort: false, + bodySize: 6, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '5', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '11', + }, + headersSpecified: true, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + }, + }, + role: null, + uploadUrl: null, + url: 'upload', + requestIndex: 1, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '5', + 'Upload-Complete': '?1', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', + }, + ], + scenarioId: 'ietfDraft03ResumeWithoutKnownLength', + events: [ + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 5, + bytesTotal: 11, + kind: 'progress', + key: 'progress:5:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 6, + kind: 'chunk-complete', + key: 'chunk-complete:6:11:11', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: no file or stream to upload provided', + reason: 'missingInput', + }, + featureId: 'startOptionValidation', + input: { + content: '', + endpointUrl: 'https://tus.io/uploads', + kind: 'none', + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationMissingInput', + events: [], + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: neither an endpoint or an upload URL is provided', + reason: 'missingEndpointOrUploadUrl', + }, + featureId: 'startOptionValidation', + input: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + primitives: ['validate-start-options'], + requests: [], + scenarioId: 'startValidationMissingEndpointOrUploadUrl', + events: [], + }, + { + behavior: 'start-option-validation', + completion: { + kind: 'error', + message: 'tus: unsupported protocol tus-v9', + reason: 'unsupportedProtocol', + }, + featureId: 'startOptionValidation', input: { content: 'hello world', endpointUrl: 'https://tus.io/uploads', @@ -2704,11 +3023,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - 'X-Request-ID': 'contract-request-id', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2717,11 +3033,19 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, + effectiveHeaders: {}, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': 'contract-request-id', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'detailedCreateResponseError', @@ -2759,11 +3083,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: 'socket down', headerMode: null, - headers: { - 'Upload-Length': '11', - 'X-Request-ID': 'contract-request-id', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: null, @@ -2771,6 +3092,13 @@ export const tusClientConformanceScenarios = [ uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': 'contract-request-id', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'detailedCreateRequestError', @@ -2800,10 +3128,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2814,11 +3140,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/upload-body-headers-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -2840,11 +3176,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'uploadBodyHeaders', @@ -2861,7 +3207,6 @@ export const tusClientConformanceScenarios = [ content: 'hello world', endpointUrl: 'https://tus.io/uploads', headers: { - 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -2879,13 +3224,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - 'Content-Type': 'application/x-tus-custom-body', - 'X-Tus-Contract': 'custom-header', - 'X-Tus-Trace': 'trace-123', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2896,11 +3236,23 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/custom-headers-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -2910,9 +3262,6 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Offset': '0', - 'Content-Type': 'application/x-tus-custom-body', - 'X-Tus-Contract': 'custom-header', - 'X-Tus-Trace': 'trace-123', }, headersSpecified: true, method: null, @@ -2925,11 +3274,23 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'customRequestHeaders', @@ -2964,11 +3325,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - 'X-Request-ID': '00000000-0000-4000-8000-000000000000', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -2979,11 +3337,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/request-id-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -2993,7 +3362,6 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Offset': '0', - 'X-Request-ID': '00000000-0000-4000-8000-000000000000', }, headersSpecified: true, method: null, @@ -3006,11 +3374,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'requestIdHeaders', @@ -3070,11 +3449,20 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 200, + effectiveHeaders: { + 'Upload-Length': '11', + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, }, role: 'recover-upload-offset', uploadUrl: null, url: 'upload', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'HEAD', }, { absentHeaders: [], @@ -3096,11 +3484,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'resumeFromPreviousUpload', @@ -3188,10 +3586,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -3202,11 +3598,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'relative-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -3228,11 +3634,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'relativeLocationResolution', @@ -3294,10 +3710,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -3308,11 +3722,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/array-buffer-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -3334,11 +3758,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'arrayBufferInput', @@ -3383,10 +3817,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -3397,11 +3829,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/array-buffer-view-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -3423,11 +3865,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'arrayBufferViewInput', @@ -3474,10 +3926,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Defer-Length': '1', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -3488,11 +3938,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/web-stream-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -3501,7 +3961,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Upload-Length': '11', 'Upload-Offset': '0', }, headersSpecified: true, @@ -3515,11 +3974,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'webReadableStreamInput', @@ -3566,10 +4036,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Defer-Length': '1', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -3580,11 +4048,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/node-stream-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -3593,7 +4071,6 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { - 'Upload-Length': '11', 'Upload-Offset': '0', }, headersSpecified: true, @@ -3607,11 +4084,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'nodeReadableStreamInput', @@ -3657,10 +4145,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -3671,11 +4157,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/node-path-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -3697,11 +4193,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'nodePathInput', @@ -3730,6 +4236,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/deferred-contract', }, eventPolicy: { + deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-extra-progress', progress: 'milestone', transportProgress: 'may-emit-extra-samples', @@ -3743,46 +4250,258 @@ export const tusClientConformanceScenarios = [ metadata: { filename: 'hello.txt', }, - uploadLengthDeferred: true, - }, - operationIds: ['createTusUpload', 'patchTusUpload'], - primitives: ['defer-upload-length', 'emit-progress'], - requests: [ + uploadLengthDeferred: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-progress'], + requests: [ + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/deferred-contract', + }, + headersSpecified: true, + statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/deferred-contract', + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'create-upload', + uploadUrl: null, + url: 'endpoint', + requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Offset': '0', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: null, + headers: { + 'Upload-Offset': '11', + }, + headersSpecified: true, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'upload-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', + }, + ], + scenarioId: 'deferredLengthUpload', + events: [ + { + kind: 'upload-url-available', + key: 'upload-url-available', + }, + { + bytesSent: 0, + bytesTotal: 11, + kind: 'progress', + key: 'progress:0:11', + }, + { + bytesSent: 11, + bytesTotal: 11, + kind: 'progress', + key: 'progress:11:11', + }, + { + bytesAccepted: 11, + bytesTotal: 11, + chunkSize: 11, + kind: 'chunk-complete', + key: 'chunk-complete:11:11:11', + }, + { + kind: 'success', + key: 'success', + }, + { + kind: 'source-close', + key: 'source-close', + }, + ], + }, + { + behavior: 'deferred-length-upload', + completion: { + kind: 'success', + uploadUrl: 'https://tus.io/uploads/deferred-chunked-contract', + }, + eventPolicy: { + deferredLengthBytesTotal: 'allow-known-total-before-declaration', + matching: 'exact-except-extra-progress', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + featureId: 'deferredLengthUpload', + input: { + chunkSize: 5, + content: 'hello world', + endpointUrl: 'https://tus.io/uploads', + kind: 'blob', + metadata: { + filename: 'hello.txt', + }, + uploadLengthDeferred: true, + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-chunk-complete', 'emit-progress'], + requests: [ + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/deferred-chunked-contract', + }, + headersSpecified: true, + statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/deferred-chunked-contract', + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'create-upload', + uploadUrl: null, + url: 'endpoint', + requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Offset': '0', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: null, + headers: { + 'Upload-Offset': '5', + }, + headersSpecified: true, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'upload-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', + }, { - absentHeaders: ['Upload-Length'], + absentHeaders: [], abort: false, - bodySize: null, + bodySize: 5, errorMessage: null, headerMode: null, headers: { - 'Upload-Defer-Length': '1', + 'Upload-Offset': '5', }, headersSpecified: true, method: null, - operationId: 'createTusUpload', + operationId: 'patchTusUpload', response: { body: null, headerMode: null, headers: { - Location: 'https://tus.io/uploads/deferred-contract', + 'Upload-Offset': '10', }, headersSpecified: true, - statusCode: 201, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '10', + 'Tus-Resumable': '1.0.0', + }, }, - role: 'create-upload', + role: 'upload-chunk', uploadUrl: null, - url: 'endpoint', - requestIndex: 0, + url: 'upload', + requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: [], abort: false, - bodySize: 11, + bodySize: 1, errorMessage: null, headerMode: null, headers: { - 'Upload-Length': '11', - 'Upload-Offset': '0', + 'Upload-Offset': '10', }, headersSpecified: true, method: null, @@ -3795,14 +4514,25 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, - role: 'upload-chunk', + role: 'upload-final-chunk', uploadUrl: null, url: 'upload', - requestIndex: 1, + requestIndex: 3, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + effectiveMethod: 'PATCH', }, ], - scenarioId: 'deferredLengthUpload', + scenarioId: 'deferredLengthChunkedUpload', events: [ { kind: 'upload-url-available', @@ -3810,9 +4540,47 @@ export const tusClientConformanceScenarios = [ }, { bytesSent: 0, + bytesTotal: null, + kind: 'progress', + key: 'progress:0:null', + }, + { + bytesSent: 5, + bytesTotal: null, + kind: 'progress', + key: 'progress:5:null', + }, + { + bytesAccepted: 5, + bytesTotal: null, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:5:null', + }, + { + bytesSent: 5, + bytesTotal: null, + kind: 'progress', + key: 'progress:5:null', + }, + { + bytesSent: 10, + bytesTotal: null, + kind: 'progress', + key: 'progress:10:null', + }, + { + bytesAccepted: 10, + bytesTotal: null, + chunkSize: 5, + kind: 'chunk-complete', + key: 'chunk-complete:5:10:null', + }, + { + bytesSent: 10, bytesTotal: 11, kind: 'progress', - key: 'progress:0:11', + key: 'progress:10:11', }, { bytesSent: 11, @@ -3823,9 +4591,9 @@ export const tusClientConformanceScenarios = [ { bytesAccepted: 11, bytesTotal: 11, - chunkSize: 11, + chunkSize: 1, kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', + key: 'chunk-complete:1:11:11', }, { kind: 'success', @@ -3873,11 +4641,20 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 200, + effectiveHeaders: { + 'Upload-Length': '11', + 'Upload-Offset': '3', + 'Tus-Resumable': '1.0.0', + }, }, role: 'recover-upload-offset', uploadUrl: 'https://tus.io/uploads/override-contract', url: 'upload', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'HEAD', }, { absentHeaders: [], @@ -3899,11 +4676,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: 'https://tus.io/uploads/override-contract', url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '3', + 'X-HTTP-Method-Override': 'PATCH', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'overridePatchMethod', @@ -3974,11 +4762,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/parallel-part-1', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-partial-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'Upload-Metadata': 'test d29ybGQ=', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4001,11 +4800,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/parallel-part-2', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-partial-upload', uploadUrl: null, url: 'endpoint', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'Upload-Metadata': 'test d29ybGQ=', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4027,11 +4837,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-part-1', url: 'upload', requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: [], @@ -4053,11 +4873,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '6', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-part-2', url: 'upload', requestIndex: 3, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: ['Upload-Length'], @@ -4080,11 +4910,22 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/parallel-final', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-final-upload', uploadUrl: null, url: 'endpoint', requestIndex: 4, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Concat': + 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', + 'Upload-Metadata': 'foo aGVsbG8=', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'parallelUploadConcat', @@ -4169,8 +5010,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -4183,11 +5022,24 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/parallel-cleanup-part-1', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-partial-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'Upload-Metadata': 'test d29ybGQ=', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4198,8 +5050,6 @@ export const tusClientConformanceScenarios = [ headers: { 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -4212,11 +5062,24 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/parallel-cleanup-part-2', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-partial-upload', uploadUrl: null, url: 'endpoint', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'Upload-Metadata': 'test d29ybGQ=', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4226,8 +5089,6 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Offset': '0', - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -4238,11 +5099,21 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, + effectiveHeaders: {}, }, role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', url: 'upload', requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4252,8 +5123,6 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Offset': '0', - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -4263,6 +5132,15 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', url: 'upload', requestIndex: 3, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4270,10 +5148,7 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', - }, + headers: {}, headersSpecified: true, method: null, operationId: 'terminateTusUpload', @@ -4283,11 +5158,20 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 204, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'terminate-upload', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', url: 'upload', requestIndex: 4, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'DELETE', }, { absentHeaders: [], @@ -4295,10 +5179,7 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', - }, + headers: {}, headersSpecified: true, method: null, operationId: 'terminateTusUpload', @@ -4308,11 +5189,20 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 204, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'terminate-upload', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', url: 'upload', requestIndex: 5, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'DELETE', }, ], scenarioId: 'parallelUploadAbortCleanup', @@ -4356,10 +5246,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -4370,11 +5258,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/retry-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4394,11 +5292,18 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, + effectiveHeaders: {}, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: [], @@ -4419,11 +5324,20 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 200, + effectiveHeaders: { + 'Upload-Length': '11', + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, }, role: 'recover-upload-offset', uploadUrl: null, url: 'upload', requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'HEAD', }, { absentHeaders: [], @@ -4443,11 +5357,18 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, + effectiveHeaders: {}, }, role: 'retry-upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 3, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: [], @@ -4468,11 +5389,20 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 200, + effectiveHeaders: { + 'Upload-Length': '11', + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, }, role: 'recover-upload-offset', uploadUrl: null, url: 'upload', requestIndex: 4, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'HEAD', }, { absentHeaders: [], @@ -4494,11 +5424,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-final-chunk', uploadUrl: null, url: 'upload', requestIndex: 5, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', }, ], scenarioId: 'retryPatchAfterOffsetRecovery', @@ -4562,11 +5502,20 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 200, + effectiveHeaders: { + 'Upload-Length': '11', + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, }, role: 'recover-upload-offset', uploadUrl: null, url: 'upload', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'HEAD', }, ], scenarioId: 'requestLifecycleHooks', @@ -4622,10 +5571,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: null, @@ -4633,6 +5580,12 @@ export const tusClientConformanceScenarios = [ uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, ], scenarioId: 'abortUpload', @@ -4683,12 +5636,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - 'X-Tus-Contract': 'abort-policy', - 'X-Tus-Trace': 'abort-trace-123', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -4699,11 +5648,23 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/abort-terminate-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4713,8 +5674,6 @@ export const tusClientConformanceScenarios = [ headerMode: null, headers: { 'Upload-Offset': '0', - 'X-Tus-Contract': 'abort-policy', - 'X-Tus-Trace': 'abort-trace-123', }, headersSpecified: true, method: null, @@ -4724,6 +5683,15 @@ export const tusClientConformanceScenarios = [ uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4731,10 +5699,7 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'X-Tus-Contract': 'abort-policy', - 'X-Tus-Trace': 'abort-trace-123', - }, + headers: {}, headersSpecified: true, method: null, operationId: 'terminateTusUpload', @@ -4744,11 +5709,20 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 204, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'terminate-upload', uploadUrl: null, url: 'upload', requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + effectiveMethod: 'DELETE', }, ], scenarioId: 'abortUploadAfterStoredUrl', @@ -4794,10 +5768,8 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: { - 'Upload-Length': '11', - }, - headersSpecified: true, + headers: {}, + headersSpecified: false, method: null, operationId: 'createTusUpload', response: { @@ -4808,11 +5780,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 201, + effectiveHeaders: { + Location: 'https://tus.io/uploads/terminate-contract', + 'Tus-Resumable': '1.0.0', + }, }, role: 'create-upload', uploadUrl: null, url: 'endpoint', requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', }, { absentHeaders: [], @@ -4834,11 +5816,21 @@ export const tusClientConformanceScenarios = [ }, headersSpecified: true, statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, url: 'upload', requestIndex: 1, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + effectiveMethod: 'PATCH', }, { absentHeaders: [], @@ -4856,11 +5848,16 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 423, + effectiveHeaders: {}, }, role: 'terminate-upload', uploadUrl: null, url: 'upload', requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'DELETE', }, { absentHeaders: [], @@ -4878,11 +5875,18 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 204, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'retry-terminate-upload', uploadUrl: null, url: 'upload', requestIndex: 3, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'DELETE', }, ], scenarioId: 'terminateWithRetry', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index fda9dadb7..63b826a45 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -78,13 +78,14 @@ function requestMatchesHeaderVariant(requestHeaders, variant) { } function expectRequestMatchesOperation(req, operation, request) { - expect(req.method).toBe(request.method ?? operation.method) + expect(req.method).toBe(request.effectiveMethod ?? request.method ?? operation.method) if (request.headerMode === 'exact') { return } - const expectedContentType = request.headers?.['Content-Type'] ?? operation.request.contentType + const expectedHeaders = request.effectiveHeaders ?? request.headers ?? {} + const expectedContentType = expectedHeaders['Content-Type'] ?? operation.request.contentType if (expectedContentType) { expect(req.requestHeaders['Content-Type']).toBe(expectedContentType) } else { @@ -140,6 +141,10 @@ function scenarioResponseHeadersFor(operation, response) { return response.headers ?? {} } + if (response.effectiveHeaders) { + return response.effectiveHeaders + } + return responseHeadersFor(operationResponse, response.headers) } @@ -459,7 +464,7 @@ function expectScenarioRequest(req, scenario, request) { expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) expectRequestMatchesOperation(req, operation, request) - for (const [header, value] of Object.entries(request.headers ?? {})) { + for (const [header, value] of Object.entries(request.effectiveHeaders ?? request.headers ?? {})) { expect(req.requestHeaders[header]).toBe(value) } @@ -498,7 +503,7 @@ async function abortScenarioRequest(req, scenario, request, requestIndex, observ const abortPromise = upload.abort(Boolean(scenario.input.terminateUploadOnAbort)) await wait(0) - expect(req.method).toBe(request.method ?? operation.method) + expect(req.method).toBe(request.effectiveMethod ?? request.method ?? operation.method) expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) return abortPromise From 2dbafc8701354682e20382ec500c206f2dee9df6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:21:12 +0200 Subject: [PATCH 107/155] Regenerate TUS event alternatives --- test/spec/generated-protocol-contract.js | 174 +++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index be2d14526..97c1486f6 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1881,6 +1881,16 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/generated-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2029,6 +2039,13 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2119,6 +2136,20 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2327,6 +2358,13 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2417,6 +2455,20 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2655,6 +2707,14 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2781,6 +2841,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: no file or stream to upload provided', reason: 'missingInput', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: '', @@ -2800,6 +2861,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: neither an endpoint or an upload URL is provided', reason: 'missingEndpointOrUploadUrl', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2818,6 +2880,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: unsupported protocol tus-v9', reason: 'unsupportedProtocol', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2838,6 +2901,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: the `retryDelays` option must either be an array or null', reason: 'retryDelaysNotArray', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2860,6 +2924,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', reason: 'parallelUploadsWithUploadUrl', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2881,6 +2946,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', reason: 'parallelUploadsWithUploadSize', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2902,6 +2968,7 @@ export const tusClientConformanceScenarios = [ message: 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', reason: 'parallelUploadsWithDeferredLength', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2924,6 +2991,7 @@ export const tusClientConformanceScenarios = [ 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', reason: 'parallelUploadsWithUploadDataDuringCreation', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2946,6 +3014,7 @@ export const tusClientConformanceScenarios = [ 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', reason: 'parallelBoundariesWithoutParallelUploads', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2972,6 +3041,7 @@ export const tusClientConformanceScenarios = [ 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', reason: 'parallelBoundariesLengthMismatch', }, + eventKeyAlternativeGroups: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2999,6 +3069,7 @@ export const tusClientConformanceScenarios = [ 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', reason: 'unexpectedCreateResponse', }, + eventKeyAlternativeGroups: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -3059,6 +3130,7 @@ export const tusClientConformanceScenarios = [ 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', reason: 'createUploadRequestFailed', }, + eventKeyAlternativeGroups: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -3110,6 +3182,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', }, + eventKeyAlternativeGroups: [], featureId: 'uploadBodyHeaders', input: { content: 'hello world', @@ -3202,6 +3275,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/custom-headers-contract', }, + eventKeyAlternativeGroups: [], featureId: 'customRequestHeaders', input: { content: 'hello world', @@ -3302,6 +3376,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/request-id-contract', }, + eventKeyAlternativeGroups: [], featureId: 'requestIdHeaders', input: { addRequestId: true, @@ -3401,6 +3476,18 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/resume-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3563,6 +3650,14 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/files/relative-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3692,6 +3787,11 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/array-buffer-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + ], featureId: 'inputSources', input: { content: 'hello world', @@ -3799,6 +3899,11 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + ], featureId: 'inputSources', input: { content: 'hello world', @@ -3906,6 +4011,11 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/web-stream-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + ], featureId: 'inputSources', input: { chunkSize: 100, @@ -4016,6 +4126,11 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/node-stream-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + ], featureId: 'inputSources', input: { chunkSize: 100, @@ -4127,6 +4242,11 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/node-path-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + ], featureId: 'inputSources', input: { content: 'hello world', @@ -4235,6 +4355,14 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/deferred-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + [], + [], + ], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-extra-progress', @@ -4368,6 +4496,20 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/deferred-chunked-contract', }, + eventKeyAlternativeGroups: [ + [], + ['progress:0:11'], + ['progress:5:11'], + ['chunk-complete:5:5:11'], + ['progress:5:11'], + ['progress:10:11'], + ['chunk-complete:5:10:11'], + [], + [], + [], + [], + [], + ], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-extra-progress', @@ -4611,6 +4753,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/override-contract', }, + eventKeyAlternativeGroups: [], featureId: 'overridePatchMethod', input: { content: 'hello world', @@ -4703,6 +4846,12 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/parallel-final', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + ], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -4963,6 +5112,9 @@ export const tusClientConformanceScenarios = [ completion: { kind: 'aborted', }, + eventKeyAlternativeGroups: [ + [], + ], execution: { serverRequestGates: [ { @@ -5220,6 +5372,12 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/retry-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + ], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -5473,6 +5631,12 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/request-hooks-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + [], + [], + ], featureId: 'requestLifecycleHooks', input: { content: 'hello world', @@ -5545,6 +5709,9 @@ export const tusClientConformanceScenarios = [ completion: { kind: 'aborted', }, + eventKeyAlternativeGroups: [ + [], + ], execution: { onRequestStart: [ { @@ -5603,6 +5770,9 @@ export const tusClientConformanceScenarios = [ kind: 'aborted', uploadUrl: 'https://tus.io/uploads/abort-terminate-contract', }, + eventKeyAlternativeGroups: [ + [], + ], execution: { onRequestStart: [ { @@ -5740,6 +5910,10 @@ export const tusClientConformanceScenarios = [ kind: 'terminated', uploadUrl: 'https://tus.io/uploads/terminate-contract', }, + eventKeyAlternativeGroups: [ + [], + [], + ], execution: { onChunkComplete: [ { From c8cdaf827f8f4a4029f7f6aa8059a61d8459013e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:24:37 +0200 Subject: [PATCH 108/155] Format generated TUS event alternatives --- test/spec/generated-protocol-contract.js | 165 +++-------------------- 1 file changed, 21 insertions(+), 144 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 97c1486f6..125492c65 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1881,16 +1881,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/generated-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2039,13 +2030,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2136,20 +2121,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2358,13 +2330,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2455,20 +2421,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2707,14 +2660,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3476,18 +3422,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/resume-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3650,14 +3585,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/files/relative-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3787,11 +3715,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/array-buffer-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], []], featureId: 'inputSources', input: { content: 'hello world', @@ -3899,11 +3823,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], []], featureId: 'inputSources', input: { content: 'hello world', @@ -4011,11 +3931,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/web-stream-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], []], featureId: 'inputSources', input: { chunkSize: 100, @@ -4126,11 +4042,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/node-stream-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], []], featureId: 'inputSources', input: { chunkSize: 100, @@ -4242,11 +4154,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/node-path-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], []], featureId: 'inputSources', input: { content: 'hello world', @@ -4355,14 +4263,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/deferred-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], [], [], []], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-extra-progress', @@ -4846,12 +4747,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/parallel-final', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], []], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -5112,9 +5008,7 @@ export const tusClientConformanceScenarios = [ completion: { kind: 'aborted', }, - eventKeyAlternativeGroups: [ - [], - ], + eventKeyAlternativeGroups: [[]], execution: { serverRequestGates: [ { @@ -5372,12 +5266,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/retry-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], []], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -5631,12 +5520,7 @@ export const tusClientConformanceScenarios = [ kind: 'success', uploadUrl: 'https://tus.io/uploads/request-hooks-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - [], - [], - ], + eventKeyAlternativeGroups: [[], [], [], []], featureId: 'requestLifecycleHooks', input: { content: 'hello world', @@ -5709,9 +5593,7 @@ export const tusClientConformanceScenarios = [ completion: { kind: 'aborted', }, - eventKeyAlternativeGroups: [ - [], - ], + eventKeyAlternativeGroups: [[]], execution: { onRequestStart: [ { @@ -5770,9 +5652,7 @@ export const tusClientConformanceScenarios = [ kind: 'aborted', uploadUrl: 'https://tus.io/uploads/abort-terminate-contract', }, - eventKeyAlternativeGroups: [ - [], - ], + eventKeyAlternativeGroups: [[]], execution: { onRequestStart: [ { @@ -5910,10 +5790,7 @@ export const tusClientConformanceScenarios = [ kind: 'terminated', uploadUrl: 'https://tus.io/uploads/terminate-contract', }, - eventKeyAlternativeGroups: [ - [], - [], - ], + eventKeyAlternativeGroups: [[], []], execution: { onChunkComplete: [ { From 83646d015c22949a3bf4920c0f1c18b67f29c66b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:37:17 +0200 Subject: [PATCH 109/155] Regenerate TUS extra event prefixes --- test/spec/generated-protocol-contract.js | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 125492c65..5e4ab8569 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1882,6 +1882,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/generated-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2031,6 +2032,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', }, eventKeyAlternativeGroups: [[], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2122,6 +2124,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2331,6 +2334,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', }, eventKeyAlternativeGroups: [[], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2422,6 +2426,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2661,6 +2666,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -2788,6 +2794,7 @@ export const tusClientConformanceScenarios = [ reason: 'missingInput', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: '', @@ -2808,6 +2815,7 @@ export const tusClientConformanceScenarios = [ reason: 'missingEndpointOrUploadUrl', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2827,6 +2835,7 @@ export const tusClientConformanceScenarios = [ reason: 'unsupportedProtocol', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2848,6 +2857,7 @@ export const tusClientConformanceScenarios = [ reason: 'retryDelaysNotArray', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2871,6 +2881,7 @@ export const tusClientConformanceScenarios = [ reason: 'parallelUploadsWithUploadUrl', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2893,6 +2904,7 @@ export const tusClientConformanceScenarios = [ reason: 'parallelUploadsWithUploadSize', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2915,6 +2927,7 @@ export const tusClientConformanceScenarios = [ reason: 'parallelUploadsWithDeferredLength', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2938,6 +2951,7 @@ export const tusClientConformanceScenarios = [ reason: 'parallelUploadsWithUploadDataDuringCreation', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2961,6 +2975,7 @@ export const tusClientConformanceScenarios = [ reason: 'parallelBoundariesWithoutParallelUploads', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2988,6 +3003,7 @@ export const tusClientConformanceScenarios = [ reason: 'parallelBoundariesLengthMismatch', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3016,6 +3032,7 @@ export const tusClientConformanceScenarios = [ reason: 'unexpectedCreateResponse', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -3077,6 +3094,7 @@ export const tusClientConformanceScenarios = [ reason: 'createUploadRequestFailed', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -3129,6 +3147,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'uploadBodyHeaders', input: { content: 'hello world', @@ -3222,6 +3241,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/custom-headers-contract', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'customRequestHeaders', input: { content: 'hello world', @@ -3323,6 +3343,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/request-id-contract', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'requestIdHeaders', input: { addRequestId: true, @@ -3423,6 +3444,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/resume-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3586,6 +3608,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/files/relative-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -3716,6 +3739,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/array-buffer-contract', }, eventKeyAlternativeGroups: [[], [], []], + eventKeyExtraPrefixes: [], featureId: 'inputSources', input: { content: 'hello world', @@ -3824,6 +3848,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', }, eventKeyAlternativeGroups: [[], [], []], + eventKeyExtraPrefixes: [], featureId: 'inputSources', input: { content: 'hello world', @@ -3932,6 +3957,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/web-stream-contract', }, eventKeyAlternativeGroups: [[], [], []], + eventKeyExtraPrefixes: [], featureId: 'inputSources', input: { chunkSize: 100, @@ -4043,6 +4069,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/node-stream-contract', }, eventKeyAlternativeGroups: [[], [], []], + eventKeyExtraPrefixes: [], featureId: 'inputSources', input: { chunkSize: 100, @@ -4155,6 +4182,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/node-path-contract', }, eventKeyAlternativeGroups: [[], [], []], + eventKeyExtraPrefixes: [], featureId: 'inputSources', input: { content: 'hello world', @@ -4264,6 +4292,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/deferred-contract', }, eventKeyAlternativeGroups: [[], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-extra-progress', @@ -4411,6 +4440,7 @@ export const tusClientConformanceScenarios = [ [], [], ], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-extra-progress', @@ -4655,6 +4685,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/override-contract', }, eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], featureId: 'overridePatchMethod', input: { content: 'hello world', @@ -4748,6 +4779,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/parallel-final', }, eventKeyAlternativeGroups: [[], [], [], []], + eventKeyExtraPrefixes: ['progress:'], eventPolicy: { matching: 'exact-except-extra-progress', progress: 'milestone', @@ -5009,6 +5041,7 @@ export const tusClientConformanceScenarios = [ kind: 'aborted', }, eventKeyAlternativeGroups: [[]], + eventKeyExtraPrefixes: [], execution: { serverRequestGates: [ { @@ -5267,6 +5300,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/retry-contract', }, eventKeyAlternativeGroups: [[], [], [], []], + eventKeyExtraPrefixes: [], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -5521,6 +5555,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/request-hooks-contract', }, eventKeyAlternativeGroups: [[], [], [], []], + eventKeyExtraPrefixes: [], featureId: 'requestLifecycleHooks', input: { content: 'hello world', @@ -5594,6 +5629,7 @@ export const tusClientConformanceScenarios = [ kind: 'aborted', }, eventKeyAlternativeGroups: [[]], + eventKeyExtraPrefixes: [], execution: { onRequestStart: [ { @@ -5653,6 +5689,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/abort-terminate-contract', }, eventKeyAlternativeGroups: [[]], + eventKeyExtraPrefixes: [], execution: { onRequestStart: [ { @@ -5791,6 +5828,7 @@ export const tusClientConformanceScenarios = [ uploadUrl: 'https://tus.io/uploads/terminate-contract', }, eventKeyAlternativeGroups: [[], []], + eventKeyExtraPrefixes: [], execution: { onChunkComplete: [ { From 88ae88422b241aeb5ae0d0764923bd9ce76483f5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:24:51 +0200 Subject: [PATCH 110/155] Use generated TUS event key templates --- test/spec/generated-protocol-contract.js | 151 ++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 80 ++++------ 2 files changed, 178 insertions(+), 53 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 5e4ab8569..5ea25973a 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -910,6 +910,157 @@ export const tusClientFeatures = [ }, ] +export const tusClientConformanceEventKeyTemplates = [ + { + eventKind: 'after-response', + fields: [ + { + name: 'requestIndex', + valueKind: 'number', + }, + ], + }, + { + eventKind: 'before-request', + fields: [ + { + name: 'requestIndex', + valueKind: 'number', + }, + ], + }, + { + eventKind: 'chunk-complete', + fields: [ + { + name: 'chunkSize', + valueKind: 'number', + }, + { + name: 'bytesAccepted', + valueKind: 'number', + }, + { + name: 'bytesTotal', + valueKind: 'nullable-number', + }, + ], + }, + { + eventKind: 'fingerprint', + fields: [ + { + name: 'fingerprint', + valueKind: 'nullable-string', + }, + ], + }, + { + eventKind: 'progress', + fields: [ + { + name: 'bytesSent', + valueKind: 'number', + }, + { + name: 'bytesTotal', + valueKind: 'nullable-number', + }, + ], + }, + { + eventKind: 'request-abort', + fields: [ + { + name: 'requestIndex', + valueKind: 'number', + }, + ], + }, + { + eventKind: 'retry-schedule', + fields: [ + { + name: 'delay', + valueKind: 'number', + }, + ], + }, + { + eventKind: 'should-retry', + fields: [ + { + name: 'retryAttempt', + valueKind: 'number', + }, + { + name: 'decision', + valueKind: 'boolean', + }, + ], + }, + { + eventKind: 'source-close', + fields: [], + }, + { + eventKind: 'source-open', + fields: [ + { + name: 'inputKind', + valueKind: 'string', + }, + { + name: 'size', + valueKind: 'nullable-number', + }, + ], + }, + { + eventKind: 'success', + fields: [], + }, + { + eventKind: 'upload-url-available', + fields: [], + }, + { + eventKind: 'url-storage-add', + fields: [ + { + name: 'fingerprint', + valueKind: 'string', + }, + { + name: 'uploadUrl', + valueKind: 'nullable-string', + }, + ], + }, + { + eventKind: 'url-storage-find', + fields: [ + { + name: 'fingerprint', + valueKind: 'string', + }, + { + name: 'count', + valueKind: 'number', + }, + ], + }, + { + eventKind: 'url-storage-remove', + fields: [ + { + name: 'urlStorageKey', + valueKind: 'string', + }, + ], + }, +] + export const tusManagedUpload = { capabilities: { cleanup: { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 63b826a45..f5f81762c 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -1,5 +1,6 @@ import { defaultOptions, Upload } from 'tus-js-client' import { + tusClientConformanceEventKeyTemplates, tusClientConformanceScenarios, tusClientFeatures, tusClientScenarioProofCases, @@ -301,62 +302,33 @@ function makeEventRecordingFileReader(fileReader, scenario, observedEvents) { } } -function formatEventValue(value) { - return value == null ? 'null' : String(value) -} - -function observedEventKey(event) { - if (event.kind === 'progress') { - return `progress:${event.bytesSent}:${formatEventValue(event.bytesTotal)}` - } +const eventKeyTemplateByKind = new Map( + tusClientConformanceEventKeyTemplates.map((template) => [template.eventKind, template]), +) - if (event.kind === 'chunk-complete') { - return `chunk-complete:${event.chunkSize}:${event.bytesAccepted}:${formatEventValue( - event.bytesTotal, - )}` - } - - if (event.kind === 'before-request') { - return `before-request:${event.requestIndex}` - } - - if (event.kind === 'after-response') { - return `after-response:${event.requestIndex}` - } - - if (event.kind === 'request-abort') { - return `request-abort:${event.requestIndex}` - } - - if (event.kind === 'source-open') { - return `source-open:${event.inputKind}:${formatEventValue(event.size)}` - } - - if (event.kind === 'fingerprint') { - return `fingerprint:${formatEventValue(event.fingerprint)}` - } - - if (event.kind === 'should-retry') { - return `should-retry:${event.retryAttempt}:${event.decision}` - } - - if (event.kind === 'retry-schedule') { - return `retry-schedule:${event.delay}` - } +function eventTemplateFieldValue(event, field) { + const value = event[field.name] + if (value == null) { + if (field.valueKind.startsWith('nullable-')) { + return 'null' + } - if (event.kind === 'url-storage-add') { - return `url-storage-add:${event.fingerprint}:${event.uploadUrl}` + throw new Error( + `Generated observed event ${event.kind} is missing non-nullable key field ${field.name}`, + ) } - if (event.kind === 'url-storage-find') { - return `url-storage-find:${event.fingerprint}:${event.count}` - } + return String(value) +} - if (event.kind === 'url-storage-remove') { - return `url-storage-remove:${event.urlStorageKey}` +function observedEventKey(event) { + const template = eventKeyTemplateByKind.get(event.kind) + if (!template) { + throw new Error(`Generated observed event ${event.kind} has no event-key template`) } - return event.kind + const parts = template.fields.map((field) => eventTemplateFieldValue(event, field)) + return parts.length === 0 ? event.kind : [event.kind, ...parts].join(':') } function expectedEventKey(scenario, event) { @@ -371,8 +343,8 @@ function expectedEventKey(scenario, event) { return event.key } -function isProgressEventKey(eventKey) { - return eventKey.startsWith('progress:') +function hasAllowedExtraEventPrefix(scenario, eventKey) { + return scenario.eventKeyExtraPrefixes.some((prefix) => eventKey.startsWith(prefix)) } function expectScenarioEventsExactExceptExtraProgress( @@ -389,10 +361,12 @@ function expectScenarioEventsExactExceptExtraProgress( continue } - expect(isProgressEventKey(observedEventKey)) + expect(hasAllowedExtraEventPrefix(scenario, observedEventKey)) .withContext( - `Expected generated scenario ${scenario.scenarioId} to only emit extra progress samples; observed events ${JSON.stringify( + `Expected generated scenario ${scenario.scenarioId} to only emit allowed extra event keys; observed events ${JSON.stringify( observedEvents, + )}; allowed prefixes ${JSON.stringify( + scenario.eventKeyExtraPrefixes, )}; expected keys ${JSON.stringify(expectedEventKeys)}`, ) .toBe(true) From 016823caadafcc750658e751cf82549cc1dcf4e3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:30:04 +0200 Subject: [PATCH 111/155] Use generic TUS extra event matching policy --- test/spec/generated-protocol-contract.js | 22 +++++++++---------- test/spec/test-generated-protocol-contract.js | 6 ++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 5ea25973a..a009fc332 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2035,7 +2035,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2185,7 +2185,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2277,7 +2277,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2487,7 +2487,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2579,7 +2579,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -2819,7 +2819,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -3597,7 +3597,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -3761,7 +3761,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -4446,7 +4446,7 @@ export const tusClientConformanceScenarios = [ eventKeyExtraPrefixes: ['progress:'], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -4594,7 +4594,7 @@ export const tusClientConformanceScenarios = [ eventKeyExtraPrefixes: ['progress:'], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, @@ -4932,7 +4932,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventPolicy: { - matching: 'exact-except-extra-progress', + matching: 'exact-except-allowed-extra-events', progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index f5f81762c..93cc6c077 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -347,7 +347,7 @@ function hasAllowedExtraEventPrefix(scenario, eventKey) { return scenario.eventKeyExtraPrefixes.some((prefix) => eventKey.startsWith(prefix)) } -function expectScenarioEventsExactExceptExtraProgress( +function expectScenarioEventsExactExceptAllowedExtraEvents( scenario, observedEvents, observedEventKeys, @@ -386,8 +386,8 @@ function expectScenarioEvents(scenario, observedEvents) { const observedEventKeys = observedEvents.map(observedEventKey) const eventPolicy = scenario.eventPolicy ?? { matching: 'exact' } - if (eventPolicy.matching === 'exact-except-extra-progress') { - expectScenarioEventsExactExceptExtraProgress( + if (eventPolicy.matching === 'exact-except-allowed-extra-events') { + expectScenarioEventsExactExceptAllowedExtraEvents( scenario, observedEvents, observedEventKeys, From b496e3e3bb2dfd2ca450c8b73a6c2e46ee78dc65 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:33:22 +0200 Subject: [PATCH 112/155] Use generated TUS fixture event keys --- test/spec/generated-protocol-contract.js | 221 ++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 16 +- 2 files changed, 223 insertions(+), 14 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index a009fc332..de2e28f7e 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2034,6 +2034,16 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'fingerprint:contract-single-fingerprint', + 'upload-url-available', + 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2184,6 +2194,13 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2276,6 +2293,20 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:0:11', + 'progress:5:11', + 'upload-url-available', + 'chunk-complete:5:5:11', + 'progress:5:11', + 'progress:10:11', + 'chunk-complete:5:10:11', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2486,6 +2517,13 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2578,6 +2616,20 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:0:11', + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:5:11', + 'progress:10:11', + 'chunk-complete:5:10:11', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2818,6 +2870,14 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2946,6 +3006,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: '', @@ -2967,6 +3031,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2987,6 +3055,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3009,6 +3081,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3033,6 +3109,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3056,6 +3136,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3079,6 +3163,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3103,6 +3191,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3127,6 +3219,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3155,6 +3251,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3184,6 +3284,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'detailedErrors', input: { content: 'hello world', @@ -3246,6 +3350,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'detailedErrors', input: { content: 'hello world', @@ -3299,6 +3407,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'uploadBodyHeaders', input: { content: 'hello world', @@ -3393,6 +3505,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'customRequestHeaders', input: { content: 'hello world', @@ -3495,6 +3611,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'requestIdHeaders', input: { addRequestId: true, @@ -3596,6 +3716,18 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'fingerprint:contract-resume-fingerprint', + 'url-storage-find:contract-resume-fingerprint:1', + 'fingerprint:contract-resume-fingerprint', + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'url-storage-remove:tus::contract-resume-fingerprint::1337', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -3760,6 +3892,14 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -3891,6 +4031,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], + eventKeys: ['source-open:array-buffer:11', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, featureId: 'inputSources', input: { content: 'hello world', @@ -4000,6 +4144,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], + eventKeys: ['source-open:array-buffer-view:11', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, featureId: 'inputSources', input: { content: 'hello world', @@ -4109,6 +4257,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], + eventKeys: ['source-open:web-readable-stream:null', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, featureId: 'inputSources', input: { chunkSize: 100, @@ -4221,6 +4373,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], + eventKeys: ['source-open:node-readable-stream:null', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, featureId: 'inputSources', input: { chunkSize: 100, @@ -4334,6 +4490,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], + eventKeys: ['source-open:node-path-reference:11', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, featureId: 'inputSources', input: { content: 'hello world', @@ -4444,6 +4604,14 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-allowed-extra-events', @@ -4592,6 +4760,20 @@ export const tusClientConformanceScenarios = [ [], ], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:0:null', + 'progress:5:null', + 'chunk-complete:5:5:null', + 'progress:5:null', + 'progress:10:null', + 'chunk-complete:5:10:null', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-allowed-extra-events', @@ -4837,6 +5019,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], + eventKeys: [], + eventPolicy: { + matching: 'exact', + }, featureId: 'overridePatchMethod', input: { content: 'hello world', @@ -4931,6 +5117,12 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -5193,6 +5385,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], + eventKeys: ['request-abort:3'], + eventPolicy: { + matching: 'exact', + }, execution: { serverRequestGates: [ { @@ -5452,6 +5648,15 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: [], + eventKeys: [ + 'should-retry:0:true', + 'retry-schedule:0', + 'should-retry:0:true', + 'retry-schedule:0', + ], + eventPolicy: { + matching: 'exact', + }, featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -5707,6 +5912,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: [], + eventKeys: ['before-request:0', 'after-response:0', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, featureId: 'requestLifecycleHooks', input: { content: 'hello world', @@ -5781,6 +5990,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], + eventKeys: ['request-abort:0'], + eventPolicy: { + matching: 'exact', + }, execution: { onRequestStart: [ { @@ -5841,6 +6054,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], + eventKeys: ['request-abort:1'], + eventPolicy: { + matching: 'exact', + }, execution: { onRequestStart: [ { @@ -5980,6 +6197,10 @@ export const tusClientConformanceScenarios = [ }, eventKeyAlternativeGroups: [[], []], eventKeyExtraPrefixes: [], + eventKeys: ['should-retry:0:true', 'retry-schedule:0'], + eventPolicy: { + matching: 'exact', + }, execution: { onChunkComplete: [ { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 93cc6c077..2b55e979b 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -331,18 +331,6 @@ function observedEventKey(event) { return parts.length === 0 ? event.kind : [event.kind, ...parts].join(':') } -function expectedEventKey(scenario, event) { - if (event.key == null) { - throw new Error( - `Generated scenario ${scenario.scenarioId} has an event without a generated key: ${JSON.stringify( - event, - )}`, - ) - } - - return event.key -} - function hasAllowedExtraEventPrefix(scenario, eventKey) { return scenario.eventKeyExtraPrefixes.some((prefix) => eventKey.startsWith(prefix)) } @@ -382,9 +370,9 @@ function expectScenarioEventsExactExceptAllowedExtraEvents( } function expectScenarioEvents(scenario, observedEvents) { - const expectedEventKeys = scenario.events.map((event) => expectedEventKey(scenario, event)) + const expectedEventKeys = scenario.eventKeys const observedEventKeys = observedEvents.map(observedEventKey) - const eventPolicy = scenario.eventPolicy ?? { matching: 'exact' } + const eventPolicy = scenario.eventPolicy if (eventPolicy.matching === 'exact-except-allowed-extra-events') { expectScenarioEventsExactExceptAllowedExtraEvents( From f66a2128add8f72b27ce66772ac87067a1177ca2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:49:07 +0200 Subject: [PATCH 113/155] Use generated TUS retry decisions --- test/spec/generated-protocol-contract.js | 52 +++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 12 ++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index de2e28f7e..ba1e2f5ff 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2140,6 +2140,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'singleUploadLifecycle', events: [ { @@ -2257,6 +2258,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'creationWithUpload', events: [ { @@ -2436,6 +2438,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'creationWithUploadPartialChunk', events: [ { @@ -2580,6 +2583,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'ietfDraft05CreationWithUpload', events: [ { @@ -2789,6 +2793,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'ietfDraft05ChunkedUploadComplete', events: [ { @@ -2962,6 +2967,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'ietfDraft03ResumeWithoutKnownLength', events: [ { @@ -3019,6 +3025,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationMissingInput', events: [], }, @@ -3043,6 +3050,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationMissingEndpointOrUploadUrl', events: [], }, @@ -3069,6 +3077,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationUnsupportedProtocol', events: [], }, @@ -3097,6 +3106,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationRetryDelaysNotArray', events: [], }, @@ -3124,6 +3134,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithUploadUrl', events: [], }, @@ -3151,6 +3162,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithUploadSize', events: [], }, @@ -3178,6 +3190,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithDeferredLength', events: [], }, @@ -3206,6 +3219,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', events: [], }, @@ -3238,6 +3252,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', events: [], }, @@ -3271,6 +3286,7 @@ export const tusClientConformanceScenarios = [ operationIds: [], primitives: ['validate-start-options'], requests: [], + retryDecisions: [], scenarioId: 'startValidationParallelBoundariesLengthMismatch', events: [], }, @@ -3337,6 +3353,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'detailedCreateResponseError', events: [], }, @@ -3396,6 +3413,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'detailedCreateRequestError', events: [], }, @@ -3494,6 +3512,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'uploadBodyHeaders', events: [], }, @@ -3600,6 +3619,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'customRequestHeaders', events: [], }, @@ -3705,6 +3725,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'requestIdHeaders', events: [], }, @@ -3828,6 +3849,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'resumeFromPreviousUpload', events: [ { @@ -3988,6 +4010,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'relativeLocationResolution', events: [ { @@ -4118,6 +4141,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'arrayBufferInput', events: [ { @@ -4231,6 +4255,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'arrayBufferViewInput', events: [ { @@ -4347,6 +4372,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'webReadableStreamInput', events: [ { @@ -4463,6 +4489,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'nodeReadableStreamInput', events: [ { @@ -4577,6 +4604,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'nodePathInput', events: [ { @@ -4704,6 +4732,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'deferredLengthUpload', events: [ { @@ -4938,6 +4967,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [], scenarioId: 'deferredLengthChunkedUpload', events: [ { @@ -5106,6 +5136,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'overridePatchMethod', events: [], }, @@ -5348,6 +5379,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'parallelUploadConcat', events: [ { @@ -5631,6 +5663,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'DELETE', }, ], + retryDecisions: [], scenarioId: 'parallelUploadAbortCleanup', events: [ { @@ -5878,6 +5911,16 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'PATCH', }, ], + retryDecisions: [ + { + decision: true, + retryAttempt: 0, + }, + { + decision: true, + retryAttempt: 0, + }, + ], scenarioId: 'retryPatchAfterOffsetRecovery', events: [ { @@ -5961,6 +6004,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'HEAD', }, ], + retryDecisions: [], scenarioId: 'requestLifecycleHooks', events: [ { @@ -6037,6 +6081,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'POST', }, ], + retryDecisions: [], scenarioId: 'abortUpload', events: [ { @@ -6180,6 +6225,7 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'DELETE', }, ], + retryDecisions: [], scenarioId: 'abortUploadAfterStoredUrl', events: [ { @@ -6350,6 +6396,12 @@ export const tusClientConformanceScenarios = [ effectiveMethod: 'DELETE', }, ], + retryDecisions: [ + { + decision: true, + retryAttempt: 0, + }, + ], scenarioId: 'terminateWithRetry', events: [ { diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 2b55e979b..3f7c9ef22 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -540,24 +540,22 @@ async function startScenarioUpload(scenario, testStack) { ) } - if (scenarioWantsEvent(scenario, 'should-retry')) { + if (scenario.retryDecisions.length > 0) { options.onShouldRetry = (_error, retryAttempt) => { - const event = scenario.events.filter((candidate) => candidate.kind === 'should-retry')[ - retryDecisionIndex - ] - if (!event) { + const retryDecision = scenario.retryDecisions[retryDecisionIndex] + if (!retryDecision) { throw new Error( `Generated scenario ${scenario.scenarioId} received unexpected retry decision request ${retryDecisionIndex}`, ) } observedEvents.push({ - decision: event.decision, + decision: retryDecision.decision, kind: 'should-retry', retryAttempt, }) retryDecisionIndex += 1 - return event.decision + return retryDecision.decision } } From 34aec24f9fb7735dc0c29c1aed1d627c6af1157a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:59:40 +0200 Subject: [PATCH 114/155] Use generated TUS event kinds --- test/spec/generated-protocol-contract.js | 719 ++---------------- test/spec/test-generated-protocol-contract.js | 2 +- 2 files changed, 56 insertions(+), 665 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index ba1e2f5ff..1076f6b70 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2044,6 +2044,15 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: [ + 'fingerprint', + 'upload-url-available', + 'url-storage-add', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2142,50 +2151,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'singleUploadLifecycle', - events: [ - { - fingerprint: 'contract-single-fingerprint', - kind: 'fingerprint', - key: 'fingerprint:contract-single-fingerprint', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - fingerprint: 'contract-single-fingerprint', - kind: 'url-storage-add', - uploadUrl: 'https://tus.io/uploads/generated-contract', - key: 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', - }, - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 11, - kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'creation-with-upload', @@ -2202,6 +2167,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['progress', 'upload-url-available', 'success', 'source-close'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2260,32 +2226,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'creationWithUpload', - events: [ - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'creation-with-upload-partial-chunk', @@ -2309,6 +2249,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['progress', 'upload-url-available', 'chunk-complete', 'success', 'source-close'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2440,77 +2381,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'creationWithUploadPartialChunk', - events: [ - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesAccepted: 5, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:5:11', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, - { - bytesAccepted: 10, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:10:11', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 1, - kind: 'chunk-complete', - key: 'chunk-complete:1:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'creation-with-upload', @@ -2527,6 +2397,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['progress', 'upload-url-available', 'success', 'source-close'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2585,32 +2456,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'ietfDraft05CreationWithUpload', - events: [ - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'upload-body-headers', @@ -2634,6 +2479,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2795,77 +2641,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'ietfDraft05ChunkedUploadComplete', - events: [ - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesAccepted: 5, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:5:11', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, - { - bytesAccepted: 10, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:10:11', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 1, - kind: 'chunk-complete', - key: 'chunk-complete:1:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'upload-body-headers', @@ -2883,6 +2658,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -2969,39 +2745,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'ietfDraft03ResumeWithoutKnownLength', - events: [ - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 6, - kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'start-option-validation', @@ -3013,6 +2756,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3027,7 +2771,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationMissingInput', - events: [], }, { behavior: 'start-option-validation', @@ -3039,6 +2782,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3052,7 +2796,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationMissingEndpointOrUploadUrl', - events: [], }, { behavior: 'start-option-validation', @@ -3064,6 +2807,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3079,7 +2823,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationUnsupportedProtocol', - events: [], }, { behavior: 'start-option-validation', @@ -3091,6 +2834,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3108,7 +2852,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationRetryDelaysNotArray', - events: [], }, { behavior: 'start-option-validation', @@ -3120,6 +2863,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3136,7 +2880,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithUploadUrl', - events: [], }, { behavior: 'start-option-validation', @@ -3148,6 +2891,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3164,7 +2908,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithUploadSize', - events: [], }, { behavior: 'start-option-validation', @@ -3176,6 +2919,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3192,7 +2936,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithDeferredLength', - events: [], }, { behavior: 'start-option-validation', @@ -3205,6 +2948,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3221,7 +2965,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', - events: [], }, { behavior: 'start-option-validation', @@ -3234,6 +2977,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3254,7 +2998,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', - events: [], }, { behavior: 'start-option-validation', @@ -3267,6 +3010,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3288,7 +3032,6 @@ export const tusClientConformanceScenarios = [ requests: [], retryDecisions: [], scenarioId: 'startValidationParallelBoundariesLengthMismatch', - events: [], }, { behavior: 'detailed-error', @@ -3301,6 +3044,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3355,7 +3099,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'detailedCreateResponseError', - events: [], }, { behavior: 'detailed-error', @@ -3368,6 +3111,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3415,7 +3159,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'detailedCreateRequestError', - events: [], }, { behavior: 'upload-body-headers', @@ -3426,6 +3169,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3514,7 +3258,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'uploadBodyHeaders', - events: [], }, { behavior: 'custom-request-headers', @@ -3525,6 +3268,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3621,7 +3365,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'customRequestHeaders', - events: [], }, { behavior: 'request-id-headers', @@ -3632,6 +3375,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -3727,7 +3471,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'requestIdHeaders', - events: [], }, { behavior: 'resume-from-previous-upload', @@ -3749,6 +3492,16 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: [ + 'fingerprint', + 'url-storage-find', + 'upload-url-available', + 'progress', + 'chunk-complete', + 'url-storage-remove', + 'success', + 'source-close', + ], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -3851,60 +3604,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'resumeFromPreviousUpload', - events: [ - { - fingerprint: 'contract-resume-fingerprint', - kind: 'fingerprint', - key: 'fingerprint:contract-resume-fingerprint', - }, - { - count: 1, - fingerprint: 'contract-resume-fingerprint', - kind: 'url-storage-find', - key: 'url-storage-find:contract-resume-fingerprint:1', - }, - { - fingerprint: 'contract-resume-fingerprint', - kind: 'fingerprint', - key: 'fingerprint:contract-resume-fingerprint', - }, - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 6, - kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', - }, - { - kind: 'url-storage-remove', - urlStorageKey: 'tus::contract-resume-fingerprint::1337', - key: 'url-storage-remove:tus::contract-resume-fingerprint::1337', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'relative-location-resolution', @@ -3922,6 +3621,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -4012,39 +3712,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'relativeLocationResolution', - events: [ - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 11, - kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'array-buffer-input', @@ -4055,6 +3722,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:array-buffer:11', 'success', 'source-close'], + eventKinds: ['source-open', 'success', 'source-close'], eventPolicy: { matching: 'exact', }, @@ -4143,22 +3811,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'arrayBufferInput', - events: [ - { - inputKind: 'array-buffer', - kind: 'source-open', - size: 11, - key: 'source-open:array-buffer:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'array-buffer-view-input', @@ -4169,6 +3821,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:array-buffer-view:11', 'success', 'source-close'], + eventKinds: ['source-open', 'success', 'source-close'], eventPolicy: { matching: 'exact', }, @@ -4257,22 +3910,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'arrayBufferViewInput', - events: [ - { - inputKind: 'array-buffer-view', - kind: 'source-open', - size: 11, - key: 'source-open:array-buffer-view:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'web-readable-stream-input', @@ -4283,6 +3920,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:web-readable-stream:null', 'success', 'source-close'], + eventKinds: ['source-open', 'success', 'source-close'], eventPolicy: { matching: 'exact', }, @@ -4374,22 +4012,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'webReadableStreamInput', - events: [ - { - inputKind: 'web-readable-stream', - kind: 'source-open', - size: null, - key: 'source-open:web-readable-stream:null', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'node-readable-stream-input', @@ -4400,6 +4022,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:node-readable-stream:null', 'success', 'source-close'], + eventKinds: ['source-open', 'success', 'source-close'], eventPolicy: { matching: 'exact', }, @@ -4491,22 +4114,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'nodeReadableStreamInput', - events: [ - { - inputKind: 'node-readable-stream', - kind: 'source-open', - size: null, - key: 'source-open:node-readable-stream:null', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], runtimes: ['node'], }, { @@ -4518,6 +4125,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:node-path-reference:11', 'success', 'source-close'], + eventKinds: ['source-open', 'success', 'source-close'], eventPolicy: { matching: 'exact', }, @@ -4606,22 +4214,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'nodePathInput', - events: [ - { - inputKind: 'node-path-reference', - kind: 'source-open', - size: 11, - key: 'source-open:node-path-reference:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], runtimes: ['node'], }, { @@ -4640,6 +4232,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-allowed-extra-events', @@ -4734,39 +4327,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'deferredLengthUpload', - events: [ - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 0, - bytesTotal: 11, - kind: 'progress', - key: 'progress:0:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 11, - kind: 'chunk-complete', - key: 'chunk-complete:11:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'deferred-length-upload', @@ -4803,6 +4363,7 @@ export const tusClientConformanceScenarios = [ 'success', 'source-close', ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], eventPolicy: { deferredLengthBytesTotal: 'allow-known-total-before-declaration', matching: 'exact-except-allowed-extra-events', @@ -4969,77 +4530,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'deferredLengthChunkedUpload', - events: [ - { - kind: 'upload-url-available', - key: 'upload-url-available', - }, - { - bytesSent: 0, - bytesTotal: null, - kind: 'progress', - key: 'progress:0:null', - }, - { - bytesSent: 5, - bytesTotal: null, - kind: 'progress', - key: 'progress:5:null', - }, - { - bytesAccepted: 5, - bytesTotal: null, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:5:null', - }, - { - bytesSent: 5, - bytesTotal: null, - kind: 'progress', - key: 'progress:5:null', - }, - { - bytesSent: 10, - bytesTotal: null, - kind: 'progress', - key: 'progress:10:null', - }, - { - bytesAccepted: 10, - bytesTotal: null, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:10:null', - }, - { - bytesSent: 10, - bytesTotal: 11, - kind: 'progress', - key: 'progress:10:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 1, - kind: 'chunk-complete', - key: 'chunk-complete:1:11:11', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'override-patch-method', @@ -5050,6 +4540,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], + eventKinds: [], eventPolicy: { matching: 'exact', }, @@ -5138,7 +4629,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'overridePatchMethod', - events: [], }, { behavior: 'parallel-upload-concat', @@ -5154,6 +4644,7 @@ export const tusClientConformanceScenarios = [ 'progress:11:11', 'chunk-complete:6:11:11', ], + eventKinds: ['progress', 'chunk-complete'], eventPolicy: { matching: 'exact-except-allowed-extra-events', progress: 'milestone', @@ -5381,34 +4872,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'parallelUploadConcat', - events: [ - { - bytesSent: 5, - bytesTotal: 11, - kind: 'progress', - key: 'progress:5:11', - }, - { - bytesAccepted: 5, - bytesTotal: 11, - chunkSize: 5, - kind: 'chunk-complete', - key: 'chunk-complete:5:5:11', - }, - { - bytesSent: 11, - bytesTotal: 11, - kind: 'progress', - key: 'progress:11:11', - }, - { - bytesAccepted: 11, - bytesTotal: 11, - chunkSize: 6, - kind: 'chunk-complete', - key: 'chunk-complete:6:11:11', - }, - ], }, { behavior: 'parallel-upload-abort-cleanup', @@ -5418,6 +4881,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], eventKeys: ['request-abort:3'], + eventKinds: ['request-abort'], eventPolicy: { matching: 'exact', }, @@ -5665,13 +5129,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'parallelUploadAbortCleanup', - events: [ - { - kind: 'request-abort', - requestIndex: 3, - key: 'request-abort:3', - }, - ], }, { behavior: 'retry-patch-after-offset-recovery', @@ -5687,6 +5144,7 @@ export const tusClientConformanceScenarios = [ 'should-retry:0:true', 'retry-schedule:0', ], + eventKinds: ['should-retry', 'retry-schedule'], eventPolicy: { matching: 'exact', }, @@ -5922,30 +5380,6 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'retryPatchAfterOffsetRecovery', - events: [ - { - decision: true, - kind: 'should-retry', - retryAttempt: 0, - key: 'should-retry:0:true', - }, - { - delay: 0, - kind: 'retry-schedule', - key: 'retry-schedule:0', - }, - { - decision: true, - kind: 'should-retry', - retryAttempt: 0, - key: 'should-retry:0:true', - }, - { - delay: 0, - kind: 'retry-schedule', - key: 'retry-schedule:0', - }, - ], }, { behavior: 'request-lifecycle-hooks', @@ -5956,6 +5390,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: [], eventKeys: ['before-request:0', 'after-response:0', 'success', 'source-close'], + eventKinds: ['before-request', 'after-response', 'success', 'source-close'], eventPolicy: { matching: 'exact', }, @@ -6006,26 +5441,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'requestLifecycleHooks', - events: [ - { - kind: 'before-request', - requestIndex: 0, - key: 'before-request:0', - }, - { - kind: 'after-response', - requestIndex: 0, - key: 'after-response:0', - }, - { - kind: 'success', - key: 'success', - }, - { - kind: 'source-close', - key: 'source-close', - }, - ], }, { behavior: 'abort-upload', @@ -6035,6 +5450,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], eventKeys: ['request-abort:0'], + eventKinds: ['request-abort'], eventPolicy: { matching: 'exact', }, @@ -6083,13 +5499,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'abortUpload', - events: [ - { - kind: 'request-abort', - requestIndex: 0, - key: 'request-abort:0', - }, - ], }, { behavior: 'abort-upload-after-stored-url', @@ -6100,6 +5509,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], eventKeys: ['request-abort:1'], + eventKinds: ['request-abort'], eventPolicy: { matching: 'exact', }, @@ -6227,13 +5637,6 @@ export const tusClientConformanceScenarios = [ ], retryDecisions: [], scenarioId: 'abortUploadAfterStoredUrl', - events: [ - { - kind: 'request-abort', - requestIndex: 1, - key: 'request-abort:1', - }, - ], }, { behavior: 'terminate-with-retry', @@ -6244,6 +5647,7 @@ export const tusClientConformanceScenarios = [ eventKeyAlternativeGroups: [[], []], eventKeyExtraPrefixes: [], eventKeys: ['should-retry:0:true', 'retry-schedule:0'], + eventKinds: ['should-retry', 'retry-schedule'], eventPolicy: { matching: 'exact', }, @@ -6403,19 +5807,6 @@ export const tusClientConformanceScenarios = [ }, ], scenarioId: 'terminateWithRetry', - events: [ - { - decision: true, - kind: 'should-retry', - retryAttempt: 0, - key: 'should-retry:0:true', - }, - { - delay: 0, - kind: 'retry-schedule', - key: 'retry-schedule:0', - }, - ], }, ] diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 3f7c9ef22..289c177e9 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -266,7 +266,7 @@ function makeEventRecordingUrlStorage(storedUpload, observedEvents) { } function scenarioWantsEvent(scenario, kind) { - return scenario.events.some((event) => event.kind === kind) + return scenario.eventKinds.includes(kind) } function scenarioExecutionActions(scenario, phase) { From 67b74e59de7a2ee3403e1b3ca4df3cbef1ca04d8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:10:45 +0200 Subject: [PATCH 115/155] Use generated TUS completion facts --- test/spec/generated-protocol-contract.js | 325 +++++++++--------- test/spec/test-generated-protocol-contract.js | 16 +- 2 files changed, 166 insertions(+), 175 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 1076f6b70..147647da7 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2028,10 +2028,10 @@ export const tusManagedUploadProofCases = [ export const tusClientConformanceScenarios = [ { behavior: 'single-upload-lifecycle', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/generated-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/generated-contract', eventKeyAlternativeGroups: [[], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -2154,10 +2154,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'creation-with-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', eventKeyAlternativeGroups: [[], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -2229,10 +2229,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'creation-with-upload-partial-chunk', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -2384,10 +2384,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'creation-with-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', eventKeyAlternativeGroups: [[], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -2459,10 +2459,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'upload-body-headers', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -2644,10 +2644,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'upload-body-headers', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -2748,11 +2748,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: no file or stream to upload provided', - reason: 'missingInput', - }, + completionKind: 'error', + completionMessage: 'tus: no file or stream to upload provided', + completionReason: 'missingInput', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2774,11 +2773,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: neither an endpoint or an upload URL is provided', - reason: 'missingEndpointOrUploadUrl', - }, + completionKind: 'error', + completionMessage: 'tus: neither an endpoint or an upload URL is provided', + completionReason: 'missingEndpointOrUploadUrl', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2799,11 +2797,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: unsupported protocol tus-v9', - reason: 'unsupportedProtocol', - }, + completionKind: 'error', + completionMessage: 'tus: unsupported protocol tus-v9', + completionReason: 'unsupportedProtocol', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2826,11 +2823,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: the `retryDelays` option must either be an array or null', - reason: 'retryDelaysNotArray', - }, + completionKind: 'error', + completionMessage: 'tus: the `retryDelays` option must either be an array or null', + completionReason: 'retryDelaysNotArray', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2855,11 +2851,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', - reason: 'parallelUploadsWithUploadUrl', - }, + completionKind: 'error', + completionMessage: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithUploadUrl', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2883,11 +2878,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', - reason: 'parallelUploadsWithUploadSize', - }, + completionKind: 'error', + completionMessage: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithUploadSize', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2911,11 +2905,11 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', - reason: 'parallelUploadsWithDeferredLength', - }, + completionKind: 'error', + completionMessage: + 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithDeferredLength', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2939,12 +2933,11 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: - 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', - reason: 'parallelUploadsWithUploadDataDuringCreation', - }, + completionKind: 'error', + completionMessage: + 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithUploadDataDuringCreation', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -2968,12 +2961,11 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: - 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', - reason: 'parallelBoundariesWithoutParallelUploads', - }, + completionKind: 'error', + completionMessage: + 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + completionReason: 'parallelBoundariesWithoutParallelUploads', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3001,12 +2993,11 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'start-option-validation', - completion: { - kind: 'error', - message: - 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', - reason: 'parallelBoundariesLengthMismatch', - }, + completionKind: 'error', + completionMessage: + 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + completionReason: 'parallelBoundariesLengthMismatch', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3035,12 +3026,11 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'detailed-error', - completion: { - kind: 'error', - message: - 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', - reason: 'unexpectedCreateResponse', - }, + completionKind: 'error', + completionMessage: + 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', + completionReason: 'unexpectedCreateResponse', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3102,12 +3092,11 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'detailed-error', - completion: { - kind: 'error', - message: - 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', - reason: 'createUploadRequestFailed', - }, + completionKind: 'error', + completionMessage: + 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', + completionReason: 'createUploadRequestFailed', + completionUploadUrl: null, eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3162,10 +3151,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'upload-body-headers', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3261,10 +3250,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'custom-request-headers', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/custom-headers-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/custom-headers-contract', eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3368,10 +3357,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'request-id-headers', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/request-id-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/request-id-contract', eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -3474,10 +3463,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'resume-from-previous-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/resume-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/resume-contract', eventKeyAlternativeGroups: [[], [], [], [], [], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -3607,10 +3596,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'relative-location-resolution', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/files/relative-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/files/relative-contract', eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -3715,10 +3704,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'array-buffer-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/array-buffer-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/array-buffer-contract', eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:array-buffer:11', 'success', 'source-close'], @@ -3814,10 +3803,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'array-buffer-view-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/array-buffer-view-contract', eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:array-buffer-view:11', 'success', 'source-close'], @@ -3913,10 +3902,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'web-readable-stream-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/web-stream-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/web-stream-contract', eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:web-readable-stream:null', 'success', 'source-close'], @@ -4015,10 +4004,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'node-readable-stream-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/node-stream-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/node-stream-contract', eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:node-readable-stream:null', 'success', 'source-close'], @@ -4118,10 +4107,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'node-path-input', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/node-path-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/node-path-contract', eventKeyAlternativeGroups: [[], [], []], eventKeyExtraPrefixes: [], eventKeys: ['source-open:node-path-reference:11', 'success', 'source-close'], @@ -4218,10 +4207,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'deferred-length-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/deferred-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/deferred-contract', eventKeyAlternativeGroups: [[], [], [], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -4330,10 +4319,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'deferred-length-upload', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/deferred-chunked-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/deferred-chunked-contract', eventKeyAlternativeGroups: [ [], ['progress:0:11'], @@ -4533,10 +4522,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'override-patch-method', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/override-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/override-contract', eventKeyAlternativeGroups: [], eventKeyExtraPrefixes: [], eventKeys: [], @@ -4632,10 +4621,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'parallel-upload-concat', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/parallel-final', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/parallel-final', eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: ['progress:'], eventKeys: [ @@ -4875,9 +4864,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'parallel-upload-abort-cleanup', - completion: { - kind: 'aborted', - }, + completionKind: 'aborted', + completionMessage: null, + completionReason: null, + completionUploadUrl: null, eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], eventKeys: ['request-abort:3'], @@ -5132,10 +5122,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'retry-patch-after-offset-recovery', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/retry-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/retry-contract', eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: [], eventKeys: [ @@ -5383,10 +5373,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'request-lifecycle-hooks', - completion: { - kind: 'success', - uploadUrl: 'https://tus.io/uploads/request-hooks-contract', - }, + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/request-hooks-contract', eventKeyAlternativeGroups: [[], [], [], []], eventKeyExtraPrefixes: [], eventKeys: ['before-request:0', 'after-response:0', 'success', 'source-close'], @@ -5444,9 +5434,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'abort-upload', - completion: { - kind: 'aborted', - }, + completionKind: 'aborted', + completionMessage: null, + completionReason: null, + completionUploadUrl: null, eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], eventKeys: ['request-abort:0'], @@ -5502,10 +5493,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'abort-upload-after-stored-url', - completion: { - kind: 'aborted', - uploadUrl: 'https://tus.io/uploads/abort-terminate-contract', - }, + completionKind: 'aborted', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/abort-terminate-contract', eventKeyAlternativeGroups: [[]], eventKeyExtraPrefixes: [], eventKeys: ['request-abort:1'], @@ -5640,10 +5631,10 @@ export const tusClientConformanceScenarios = [ }, { behavior: 'terminate-with-retry', - completion: { - kind: 'terminated', - uploadUrl: 'https://tus.io/uploads/terminate-contract', - }, + completionKind: 'terminated', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/terminate-contract', eventKeyAlternativeGroups: [[], []], eventKeyExtraPrefixes: [], eventKeys: ['should-retry:0:true', 'retry-schedule:0'], diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 289c177e9..ccfcfe214 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -412,7 +412,7 @@ function expectedUrlForScenarioRequest(scenario, request) { const uploadUrl = scenario.input.uploadUrl ?? scenario.input.storedUpload?.uploadUrl ?? - scenario.completion.uploadUrl + scenario.completionUploadUrl if (!uploadUrl) { throw new Error(`Generated scenario ${scenario.scenarioId} has no upload URL expectation`) } @@ -749,7 +749,7 @@ async function runGeneratedConformanceScenario(scenario) { } } - if (scenario.completion.kind === 'aborted') { + if (scenario.completionKind === 'aborted') { await Promise.all(abortPromises) expect(onSuccess).not.toHaveBeenCalled() expect(onError).not.toHaveBeenCalled() @@ -757,18 +757,18 @@ async function runGeneratedConformanceScenario(scenario) { return } - if (scenario.completion.kind === 'terminated') { + if (scenario.completionKind === 'terminated') { await terminatePromise() - expect(upload.url).toBe(scenario.completion.uploadUrl) + expect(upload.url).toBe(scenario.completionUploadUrl) expect(onSuccess).not.toHaveBeenCalled() expect(onError).not.toHaveBeenCalled() expectScenarioEvents(scenario, observedEvents) return } - if (scenario.completion.kind === 'error') { + if (scenario.completionKind === 'error') { const err = await onError.toBeCalled() - expect(err.message).toBe(scenario.completion.message) + expect(err.message).toBe(scenario.completionMessage) expect(onSuccess).not.toHaveBeenCalled() expect(await Promise.race([testStack.nextRequest(), wait(0)])).toBe('timed out') expectScenarioEvents(scenario, observedEvents) @@ -776,7 +776,7 @@ async function runGeneratedConformanceScenario(scenario) { } await onSuccess.toBeCalled() - expect(upload.url).toBe(scenario.completion.uploadUrl) + expect(upload.url).toBe(scenario.completionUploadUrl) expect(onError).not.toHaveBeenCalled() expectScenarioEvents(scenario, observedEvents) } finally { @@ -802,7 +802,7 @@ describe('generated TUS protocol contract', () => { const feature = getClientFeature(proofCase.featureId) expect(scenario.behavior).toBe(proofCase.behavior) - expect(scenario.completion.kind).toBe(proofCase.completionKind) + expect(scenario.completionKind).toBe(proofCase.completionKind) expect(scenario.featureId).toBe(proofCase.featureId) expect(feature.conformance.scenarioIds).toContain(scenario.scenarioId) expect(scenario.operationIds).toEqual(proofCase.operationIds) From 04b91c462b858fa0df36499ed32e47db1d03562b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:21:43 +0200 Subject: [PATCH 116/155] Use generated TUS execution phases --- test/spec/generated-protocol-contract.js | 160 ++++++++++++------ test/spec/test-generated-protocol-contract.js | 4 +- 2 files changed, 108 insertions(+), 56 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 147647da7..b2aab82a8 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2058,6 +2058,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'singleUploadLifecycle', input: { content: 'hello world', @@ -2173,6 +2174,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'creationWithUpload', input: { content: 'hello world', @@ -2255,6 +2257,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'creationWithUpload', input: { chunkSize: 5, @@ -2403,6 +2406,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'protocolVersionSelection', input: { content: 'hello world', @@ -2485,6 +2489,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'protocolVersionSelection', input: { chunkSize: 5, @@ -2664,6 +2669,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'protocolVersionSelection', input: { chunkSize: 6, @@ -2759,6 +2765,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: '', @@ -2784,6 +2791,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2808,6 +2816,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2834,6 +2843,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2862,6 +2872,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2889,6 +2900,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2917,6 +2929,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2945,6 +2958,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -2973,6 +2987,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3005,6 +3020,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'startOptionValidation', input: { content: 'hello world', @@ -3038,6 +3054,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -3104,6 +3121,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'detailedErrors', input: { content: 'hello world', @@ -3162,6 +3180,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'uploadBodyHeaders', input: { content: 'hello world', @@ -3261,6 +3280,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'customRequestHeaders', input: { content: 'hello world', @@ -3368,6 +3388,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'requestIdHeaders', input: { addRequestId: true, @@ -3496,15 +3517,18 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, - execution: { - beforeStart: [ - { - expectedPreviousUploadCount: 1, - kind: 'resume-from-previous-upload', - selectedPreviousUploadIndex: 0, - }, - ], - }, + executionActionPhases: [ + { + actions: [ + { + expectedPreviousUploadCount: 1, + kind: 'resume-from-previous-upload', + selectedPreviousUploadIndex: 0, + }, + ], + phase: 'beforeStart', + }, + ], featureId: 'resumeUpload', input: { content: 'hello world', @@ -3616,6 +3640,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'relativeLocationResolution', input: { content: 'hello world', @@ -3715,6 +3740,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'inputSources', input: { content: 'hello world', @@ -3814,6 +3840,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'inputSources', input: { content: 'hello world', @@ -3913,6 +3940,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'inputSources', input: { chunkSize: 100, @@ -4015,6 +4043,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'inputSources', input: { chunkSize: 100, @@ -4118,6 +4147,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'inputSources', input: { content: 'hello world', @@ -4228,6 +4258,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'deferredLengthUpload', input: { chunkSize: 100, @@ -4359,6 +4390,7 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, + executionActionPhases: [], featureId: 'deferredLengthUpload', input: { chunkSize: 5, @@ -4533,6 +4565,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'overridePatchMethod', input: { content: 'hello world', @@ -4639,17 +4672,20 @@ export const tusClientConformanceScenarios = [ progress: 'milestone', transportProgress: 'may-emit-extra-samples', }, - execution: { - serverRequestGates: [ - { - gateId: 'parallel-patches', - heldRequestIndexes: [2, 3], - kind: 'release-after-all-started', - releaseAfterRequestIndexes: [2, 3], - timeoutMs: 2000, - }, - ], - }, + executionActionPhases: [ + { + actions: [ + { + gateId: 'parallel-patches', + heldRequestIndexes: [2, 3], + kind: 'release-after-all-started', + releaseAfterRequestIndexes: [2, 3], + timeoutMs: 2000, + }, + ], + phase: 'serverRequestGates', + }, + ], featureId: 'parallelUploadConcat', input: { content: 'hello world', @@ -4875,17 +4911,20 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, - execution: { - serverRequestGates: [ - { - gateId: 'parallel-cleanup-patches', - heldRequestIndexes: [2, 3], - kind: 'release-after-all-started', - releaseAfterRequestIndexes: [2, 3], - timeoutMs: 2000, - }, - ], - }, + executionActionPhases: [ + { + actions: [ + { + gateId: 'parallel-cleanup-patches', + heldRequestIndexes: [2, 3], + kind: 'release-after-all-started', + releaseAfterRequestIndexes: [2, 3], + timeoutMs: 2000, + }, + ], + phase: 'serverRequestGates', + }, + ], featureId: 'parallelUploadConcat', input: { content: 'hello world', @@ -5138,6 +5177,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'retryOffsetRecovery', input: { content: 'hello world', @@ -5384,6 +5424,7 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, + executionActionPhases: [], featureId: 'requestLifecycleHooks', input: { content: 'hello world', @@ -5445,14 +5486,17 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, - execution: { - onRequestStart: [ - { - kind: 'cancel-upload', - requestIndex: 0, - }, - ], - }, + executionActionPhases: [ + { + actions: [ + { + kind: 'cancel-upload', + requestIndex: 0, + }, + ], + phase: 'onRequestStart', + }, + ], featureId: 'abortUpload', input: { content: 'hello world', @@ -5504,14 +5548,17 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, - execution: { - onRequestStart: [ - { - kind: 'cancel-upload', - requestIndex: 1, - }, - ], - }, + executionActionPhases: [ + { + actions: [ + { + kind: 'cancel-upload', + requestIndex: 1, + }, + ], + phase: 'onRequestStart', + }, + ], featureId: 'abortUpload', input: { content: 'hello world', @@ -5642,14 +5689,17 @@ export const tusClientConformanceScenarios = [ eventPolicy: { matching: 'exact', }, - execution: { - onChunkComplete: [ - { - kind: 'abort-upload', - terminateUpload: true, - }, - ], - }, + executionActionPhases: [ + { + actions: [ + { + kind: 'abort-upload', + terminateUpload: true, + }, + ], + phase: 'onChunkComplete', + }, + ], featureId: 'terminateUpload', input: { chunkSize: 5, diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index ccfcfe214..a1f1525b1 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -270,7 +270,9 @@ function scenarioWantsEvent(scenario, kind) { } function scenarioExecutionActions(scenario, phase) { - return scenario.execution?.[phase] ?? [] + return ( + scenario.executionActionPhases.find((candidate) => candidate.phase === phase)?.actions ?? [] + ) } function makeEventRecordingFileReader(fileReader, scenario, observedEvents) { From 9f577eb3b712481eae8acdcc2d0c5784bc8de685 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:32:47 +0200 Subject: [PATCH 117/155] Use generated TUS source and URLs --- test/spec/generated-protocol-contract.js | 221 ++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 32 +-- 2 files changed, 227 insertions(+), 26 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b2aab82a8..135a044aa 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2069,6 +2069,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: [ 'open-input-source', @@ -2112,6 +2116,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -2148,6 +2153,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/generated-contract', }, ], retryDecisions: [], @@ -2185,6 +2191,10 @@ export const tusClientConformanceScenarios = [ }, uploadDataDuringCreation: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload'], primitives: ['upload-during-creation', 'emit-progress'], requests: [ @@ -2224,6 +2234,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, ], retryDecisions: [], @@ -2269,6 +2280,10 @@ export const tusClientConformanceScenarios = [ }, uploadDataDuringCreation: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['upload-during-creation', 'emit-progress'], requests: [ @@ -2308,6 +2323,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -2344,6 +2360,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', }, { absentHeaders: [], @@ -2380,6 +2397,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '10', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', }, ], retryDecisions: [], @@ -2418,6 +2436,10 @@ export const tusClientConformanceScenarios = [ protocol: 'ietf-draft-05', uploadDataDuringCreation: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload'], primitives: ['select-client-protocol'], requests: [ @@ -2456,6 +2478,7 @@ export const tusClientConformanceScenarios = [ 'Content-Type': 'application/partial-upload', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, ], retryDecisions: [], @@ -2499,6 +2522,10 @@ export const tusClientConformanceScenarios = [ protocol: 'ietf-draft-05', uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['select-client-protocol'], requests: [ @@ -2534,6 +2561,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Draft-Interop-Version': '6', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, { absentHeaders: ['Tus-Resumable'], @@ -2570,6 +2598,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, { absentHeaders: ['Tus-Resumable'], @@ -2606,6 +2635,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, { absentHeaders: ['Tus-Resumable'], @@ -2642,6 +2672,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '10', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, ], retryDecisions: [], @@ -2679,6 +2710,10 @@ export const tusClientConformanceScenarios = [ protocol: 'ietf-draft-03', uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['select-client-protocol'], requests: [ @@ -2712,6 +2747,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Draft-Interop-Version': '5', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, { absentHeaders: ['Content-Type', 'Tus-Resumable'], @@ -2747,6 +2783,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, ], retryDecisions: [], @@ -2772,6 +2809,10 @@ export const tusClientConformanceScenarios = [ endpointUrl: 'https://tus.io/uploads', kind: 'none', }, + inputSource: { + content: '', + kind: 'none', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2797,6 +2838,10 @@ export const tusClientConformanceScenarios = [ content: 'hello world', kind: 'blob', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2824,6 +2869,10 @@ export const tusClientConformanceScenarios = [ kind: 'blob', protocol: 'tus-v9', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2853,6 +2902,10 @@ export const tusClientConformanceScenarios = [ retryDelays: 44, }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2881,6 +2934,10 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadUrl: 'https://tus.io/uploads/start-validation-upload-url', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2909,6 +2966,10 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadSize: 11, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2938,6 +2999,10 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadLengthDeferred: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -2967,6 +3032,10 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadDataDuringCreation: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -3000,6 +3069,10 @@ export const tusClientConformanceScenarios = [ }, ], }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -3034,6 +3107,10 @@ export const tusClientConformanceScenarios = [ ], parallelUploads: 2, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [], primitives: ['validate-start-options'], requests: [], @@ -3070,6 +3147,10 @@ export const tusClientConformanceScenarios = [ retryDelays: null, }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload'], primitives: ['report-detailed-errors'], requests: [ @@ -3102,6 +3183,7 @@ export const tusClientConformanceScenarios = [ 'X-Request-ID': 'contract-request-id', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, ], retryDecisions: [], @@ -3137,6 +3219,10 @@ export const tusClientConformanceScenarios = [ retryDelays: null, }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload'], primitives: ['report-detailed-errors'], requests: [ @@ -3162,6 +3248,7 @@ export const tusClientConformanceScenarios = [ 'X-Request-ID': 'contract-request-id', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, ], retryDecisions: [], @@ -3190,6 +3277,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['send-upload-body-headers'], requests: [ @@ -3226,6 +3317,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -3262,6 +3354,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/upload-body-headers-contract', }, ], retryDecisions: [], @@ -3294,6 +3387,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['apply-custom-request-headers'], requests: [ @@ -3332,6 +3429,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -3370,6 +3468,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'trace-123', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/custom-headers-contract', }, ], retryDecisions: [], @@ -3403,6 +3502,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['add-request-id-header', 'apply-custom-request-headers'], requests: [ @@ -3440,6 +3543,7 @@ export const tusClientConformanceScenarios = [ 'X-Request-ID': '00000000-0000-4000-8000-000000000000', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -3477,6 +3581,7 @@ export const tusClientConformanceScenarios = [ 'X-Request-ID': '00000000-0000-4000-8000-000000000000', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/request-id-contract', }, ], retryDecisions: [], @@ -3541,6 +3646,10 @@ export const tusClientConformanceScenarios = [ urlStorageKey: 'tus::contract-resume-fingerprint::1337', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], requests: [ @@ -3577,6 +3686,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/resume-contract', }, { absentHeaders: [], @@ -3613,6 +3723,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/resume-contract', }, ], retryDecisions: [], @@ -3650,6 +3761,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['resolve-relative-location'], requests: [ @@ -3686,6 +3801,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/files/', }, { absentHeaders: [], @@ -3722,6 +3838,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/files/relative-contract', }, ], retryDecisions: [], @@ -3750,6 +3867,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'array-buffer', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['read-browser-file'], requests: [ @@ -3786,6 +3907,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -3822,6 +3944,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/array-buffer-contract', }, ], retryDecisions: [], @@ -3850,6 +3973,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'array-buffer-view', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['read-browser-file'], requests: [ @@ -3886,6 +4013,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -3922,6 +4050,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/array-buffer-view-contract', }, ], retryDecisions: [], @@ -3952,6 +4081,10 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputSource: { + content: 'hello world', + kind: 'web-readable-stream', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['read-web-stream'], requests: [ @@ -3988,6 +4121,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4025,6 +4159,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/web-stream-contract', }, ], retryDecisions: [], @@ -4055,6 +4190,10 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputSource: { + content: 'hello world', + kind: 'node-readable-stream', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['read-node-stream'], requests: [ @@ -4091,6 +4230,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4128,6 +4268,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/node-stream-contract', }, ], retryDecisions: [], @@ -4157,6 +4298,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'node-path-reference', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['read-node-file'], requests: [ @@ -4193,6 +4338,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4229,6 +4375,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/node-path-contract', }, ], retryDecisions: [], @@ -4270,6 +4417,10 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputSource: { + content: 'hello world', + kind: 'web-readable-stream', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['defer-upload-length', 'emit-progress'], requests: [ @@ -4306,6 +4457,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4343,6 +4495,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/deferred-contract', }, ], retryDecisions: [], @@ -4402,6 +4555,10 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload'], primitives: ['defer-upload-length', 'emit-chunk-complete', 'emit-progress'], requests: [ @@ -4438,6 +4595,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4474,6 +4632,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/deferred-chunked-contract', }, { absentHeaders: [], @@ -4510,6 +4669,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/deferred-chunked-contract', }, { absentHeaders: [], @@ -4547,6 +4707,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '10', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/deferred-chunked-contract', }, ], retryDecisions: [], @@ -4574,6 +4735,10 @@ export const tusClientConformanceScenarios = [ overridePatchMethod: true, uploadUrl: 'https://tus.io/uploads/override-contract', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['getTusUploadOffset', 'patchTusUpload'], primitives: ['override-patch-method'], requests: [ @@ -4610,6 +4775,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/override-contract', }, { absentHeaders: [], @@ -4647,6 +4813,7 @@ export const tusClientConformanceScenarios = [ 'X-HTTP-Method-Override': 'PATCH', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads/override-contract', }, ], retryDecisions: [], @@ -4699,6 +4866,10 @@ export const tusClientConformanceScenarios = [ }, parallelUploads: 2, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [ 'createTusUpload', 'createTusUpload', @@ -4745,6 +4916,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'test d29ybGQ=', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4783,6 +4955,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'test d29ybGQ=', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -4819,6 +4992,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/parallel-part-1', }, { absentHeaders: [], @@ -4855,6 +5029,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/parallel-part-2', }, { absentHeaders: ['Upload-Length'], @@ -4893,6 +5068,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'foo aGVsbG8=', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, ], retryDecisions: [], @@ -4942,6 +5118,10 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, terminateUploadOnAbort: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [ 'createTusUpload', 'createTusUpload', @@ -4991,6 +5171,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -5031,6 +5212,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -5065,6 +5247,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', }, { absentHeaders: [], @@ -5092,6 +5275,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', }, { absentHeaders: [], @@ -5123,6 +5307,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, effectiveMethod: 'DELETE', + expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', }, { absentHeaders: [], @@ -5154,6 +5339,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, effectiveMethod: 'DELETE', + expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', }, ], retryDecisions: [], @@ -5188,6 +5374,10 @@ export const tusClientConformanceScenarios = [ }, retryDelays: [0], }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: [ 'createTusUpload', 'patchTusUpload', @@ -5231,6 +5421,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -5262,6 +5453,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/retry-contract', }, { absentHeaders: [], @@ -5296,6 +5488,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/retry-contract', }, { absentHeaders: [], @@ -5327,6 +5520,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/retry-contract', }, { absentHeaders: [], @@ -5361,6 +5555,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/retry-contract', }, { absentHeaders: [], @@ -5397,6 +5592,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '5', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/retry-contract', }, ], retryDecisions: [ @@ -5432,6 +5628,10 @@ export const tusClientConformanceScenarios = [ kind: 'blob', uploadUrl: 'https://tus.io/uploads/request-hooks-contract', }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['getTusUploadOffset'], primitives: ['run-request-hooks'], requests: [ @@ -5468,6 +5668,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'HEAD', + expectedUrl: 'https://tus.io/uploads/request-hooks-contract', }, ], retryDecisions: [], @@ -5506,6 +5707,10 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload'], primitives: ['abort-current-request'], requests: [ @@ -5530,6 +5735,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, ], retryDecisions: [], @@ -5575,6 +5781,10 @@ export const tusClientConformanceScenarios = [ overridePatchMethod: true, terminateUploadOnAbort: true, }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload', 'terminateTusUpload'], primitives: ['abort-current-request', 'terminate-upload'], requests: [ @@ -5613,6 +5823,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'abort-trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -5640,6 +5851,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'abort-trace-123', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads/abort-terminate-contract', }, { absentHeaders: [], @@ -5671,6 +5883,7 @@ export const tusClientConformanceScenarios = [ 'X-Tus-Trace': 'abort-trace-123', }, effectiveMethod: 'DELETE', + expectedUrl: 'https://tus.io/uploads/abort-terminate-contract', }, ], retryDecisions: [], @@ -5711,6 +5924,10 @@ export const tusClientConformanceScenarios = [ }, retryDelays: [0, 0], }, + inputSource: { + content: 'hello world', + kind: 'blob', + }, operationIds: ['createTusUpload', 'patchTusUpload', 'terminateTusUpload', 'terminateTusUpload'], primitives: ['terminate-upload', 'retry-with-backoff'], requests: [ @@ -5747,6 +5964,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', }, { absentHeaders: [], @@ -5783,6 +6001,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Offset': '0', }, effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/terminate-contract', }, { absentHeaders: [], @@ -5810,6 +6029,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'DELETE', + expectedUrl: 'https://tus.io/uploads/terminate-contract', }, { absentHeaders: [], @@ -5839,6 +6059,7 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', }, effectiveMethod: 'DELETE', + expectedUrl: 'https://tus.io/uploads/terminate-contract', }, ], retryDecisions: [ diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index a1f1525b1..00429a3a7 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -282,7 +282,7 @@ function makeEventRecordingFileReader(fileReader, scenario, observedEvents) { if (scenarioWantsEvent(scenario, 'source-open')) { observedEvents.push({ - inputKind: scenario.input.kind, + inputKind: scenario.inputSource.kind, kind: 'source-open', size: source.size, }) @@ -402,30 +402,10 @@ function expectScenarioEvents(scenario, observedEvents) { ) } -function expectedUrlForScenarioRequest(scenario, request) { - if (request.url === 'endpoint') { - return scenario.input.endpointUrl - } - - if (request.uploadUrl) { - return request.uploadUrl - } - - const uploadUrl = - scenario.input.uploadUrl ?? - scenario.input.storedUpload?.uploadUrl ?? - scenario.completionUploadUrl - if (!uploadUrl) { - throw new Error(`Generated scenario ${scenario.scenarioId} has no upload URL expectation`) - } - - return uploadUrl -} - -function expectScenarioRequest(req, scenario, request) { +function expectScenarioRequest(req, request) { const operation = getProtocolOperation(request.operationId) - expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) + expect(req.url).toBe(request.expectedUrl) expectRequestMatchesOperation(req, operation, request) for (const [header, value] of Object.entries(request.effectiveHeaders ?? request.headers ?? {})) { @@ -468,7 +448,7 @@ async function abortScenarioRequest(req, scenario, request, requestIndex, observ await wait(0) expect(req.method).toBe(request.effectiveMethod ?? request.method ?? operation.method) - expect(req.url).toBe(expectedUrlForScenarioRequest(scenario, request)) + expect(req.url).toBe(request.expectedUrl) return abortPromise } @@ -669,7 +649,7 @@ async function startScenarioUpload(scenario, testStack) { } } - upload = new Upload(await createScenarioInput(scenario.input), options) + upload = new Upload(await createScenarioInput(scenario.inputSource), options) for (const action of scenarioExecutionActions(scenario, 'beforeStart')) { if (action.kind === 'resume-from-previous-upload') { @@ -736,7 +716,7 @@ async function runGeneratedConformanceScenario(scenario) { const requestIndex = request.requestIndex expect(requestIndex).toBe(scenarioRequestIndex) const req = await testStack.nextRequest() - expectScenarioRequest(req, scenario, request) + expectScenarioRequest(req, request) if (request.abort) { abortPromises.push( From a8096b6406029482141925486aab500ad96cee83 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:45:09 +0200 Subject: [PATCH 118/155] Use generated TUS input option entries --- test/spec/generated-protocol-contract.js | 606 ++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 102 ++- 2 files changed, 646 insertions(+), 62 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 135a044aa..8ad403bd8 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2069,6 +2069,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2191,6 +2203,22 @@ export const tusClientConformanceScenarios = [ }, uploadDataDuringCreation: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadDataDuringCreation', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2280,6 +2308,26 @@ export const tusClientConformanceScenarios = [ }, uploadDataDuringCreation: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 5, + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadDataDuringCreation', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2436,6 +2484,26 @@ export const tusClientConformanceScenarios = [ protocol: 'ietf-draft-05', uploadDataDuringCreation: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'protocol', + value: 'ietf-draft-05', + }, + { + key: 'uploadDataDuringCreation', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2522,6 +2590,24 @@ export const tusClientConformanceScenarios = [ protocol: 'ietf-draft-05', uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 5, + }, + { + key: 'protocol', + value: 'ietf-draft-05', + }, + { + key: 'uploadUrl', + value: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2710,6 +2796,24 @@ export const tusClientConformanceScenarios = [ protocol: 'ietf-draft-03', uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 6, + }, + { + key: 'protocol', + value: 'ietf-draft-03', + }, + { + key: 'uploadUrl', + value: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2809,6 +2913,12 @@ export const tusClientConformanceScenarios = [ endpointUrl: 'https://tus.io/uploads', kind: 'none', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + ], inputSource: { content: '', kind: 'none', @@ -2838,6 +2948,7 @@ export const tusClientConformanceScenarios = [ content: 'hello world', kind: 'blob', }, + inputOptionEntries: [], inputSource: { content: 'hello world', kind: 'blob', @@ -2869,6 +2980,16 @@ export const tusClientConformanceScenarios = [ kind: 'blob', protocol: 'tus-v9', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'protocol', + value: 'tus-v9', + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2902,6 +3023,18 @@ export const tusClientConformanceScenarios = [ retryDelays: 44, }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'rawOptions', + value: { + retryDelays: 44, + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2934,6 +3067,20 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadUrl: 'https://tus.io/uploads/start-validation-upload-url', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadUrl', + value: 'https://tus.io/uploads/start-validation-upload-url', + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2966,6 +3113,20 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadSize: 11, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadSize', + value: 11, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -2999,6 +3160,20 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadLengthDeferred: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadLengthDeferred', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3032,6 +3207,20 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, uploadDataDuringCreation: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadDataDuringCreation', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3069,6 +3258,21 @@ export const tusClientConformanceScenarios = [ }, ], }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploadBoundaries', + value: [ + { + end: 5, + start: 0, + }, + ], + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3107,6 +3311,25 @@ export const tusClientConformanceScenarios = [ ], parallelUploads: 2, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'parallelUploadBoundaries', + value: [ + { + end: 5, + start: 0, + }, + ], + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3147,6 +3370,30 @@ export const tusClientConformanceScenarios = [ retryDelays: null, }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'headers', + value: { + 'X-Request-ID': 'contract-request-id', + }, + }, + { + key: 'rawOptions', + value: { + retryDelays: null, + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3219,6 +3466,30 @@ export const tusClientConformanceScenarios = [ retryDelays: null, }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'headers', + value: { + 'X-Request-ID': 'contract-request-id', + }, + }, + { + key: 'rawOptions', + value: { + retryDelays: null, + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3277,6 +3548,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3387,6 +3670,25 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'headers', + value: { + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3502,6 +3804,28 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'headers', + value: { + 'X-Request-ID': 'custom-request-id', + }, + }, + { + key: 'addRequestId', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3646,6 +3970,16 @@ export const tusClientConformanceScenarios = [ urlStorageKey: 'tus::contract-resume-fingerprint::1337', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'removeFingerprintOnSuccess', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3761,6 +4095,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/files/', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -3867,6 +4213,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'array-buffer', @@ -3973,6 +4331,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'array-buffer-view', @@ -4081,6 +4451,26 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 100, + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadLengthDeferred', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'web-readable-stream', @@ -4190,6 +4580,26 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 100, + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadLengthDeferred', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'node-readable-stream', @@ -4298,6 +4708,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'node-path-reference', @@ -4417,6 +4839,26 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 100, + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadLengthDeferred', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'web-readable-stream', @@ -4555,6 +4997,26 @@ export const tusClientConformanceScenarios = [ }, uploadLengthDeferred: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 5, + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadLengthDeferred', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -4735,6 +5197,20 @@ export const tusClientConformanceScenarios = [ overridePatchMethod: true, uploadUrl: 'https://tus.io/uploads/override-contract', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'overridePatchMethod', + value: true, + }, + { + key: 'uploadUrl', + value: 'https://tus.io/uploads/override-contract', + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -4866,6 +5342,28 @@ export const tusClientConformanceScenarios = [ }, parallelUploads: 2, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + foo: 'hello', + }, + }, + { + key: 'metadataForPartialUploads', + value: { + test: 'world', + }, + }, + { + key: 'parallelUploads', + value: 2, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -5118,6 +5616,33 @@ export const tusClientConformanceScenarios = [ parallelUploads: 2, terminateUploadOnAbort: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadataForPartialUploads', + value: { + test: 'world', + }, + }, + { + key: 'headers', + value: { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + }, + { + key: 'overridePatchMethod', + value: true, + }, + { + key: 'parallelUploads', + value: 2, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -5374,6 +5899,22 @@ export const tusClientConformanceScenarios = [ }, retryDelays: [0], }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'retryDelays', + value: [0], + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -5628,6 +6169,16 @@ export const tusClientConformanceScenarios = [ kind: 'blob', uploadUrl: 'https://tus.io/uploads/request-hooks-contract', }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'uploadUrl', + value: 'https://tus.io/uploads/request-hooks-contract', + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -5707,6 +6258,18 @@ export const tusClientConformanceScenarios = [ filename: 'hello.txt', }, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -5781,6 +6344,29 @@ export const tusClientConformanceScenarios = [ overridePatchMethod: true, terminateUploadOnAbort: true, }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'headers', + value: { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + }, + { + key: 'overridePatchMethod', + value: true, + }, + ], inputSource: { content: 'hello world', kind: 'blob', @@ -5924,6 +6510,26 @@ export const tusClientConformanceScenarios = [ }, retryDelays: [0, 0], }, + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'chunkSize', + value: 5, + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'retryDelays', + value: [0, 0], + }, + ], inputSource: { content: 'hello world', kind: 'blob', diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 00429a3a7..0fd622f84 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -208,6 +208,44 @@ async function createScenarioInput(input) { throw new Error(`Unsupported generated TUS scenario input kind: ${input.kind}`) } +const sameNameScenarioInputOptionKeys = new Set([ + 'addRequestId', + 'chunkSize', + 'headers', + 'metadata', + 'metadataForPartialUploads', + 'overridePatchMethod', + 'parallelUploadBoundaries', + 'parallelUploads', + 'protocol', + 'removeFingerprintOnSuccess', + 'retryDelays', + 'storeFingerprintForResuming', + 'uploadDataDuringCreation', + 'uploadLengthDeferred', + 'uploadSize', + 'uploadUrl', +]) + +function applyScenarioInputOption(options, entry) { + if (entry.key === 'endpointUrl') { + options.endpoint = entry.value + return + } + + if (entry.key === 'rawOptions') { + Object.assign(options, entry.value) + return + } + + if (sameNameScenarioInputOptionKeys.has(entry.key)) { + options[entry.key] = entry.value + return + } + + throw new Error(`Unsupported generated TUS input option key: ${entry.key}`) +} + function installGeneratedRequestIdRandom(scenario) { if (!scenario.input.addRequestId) { return () => {} @@ -465,9 +503,7 @@ async function startScenarioUpload(scenario, testStack) { const onError = waitableFunction('onError') const onSuccess = waitableFunction('onSuccess') const options = { - endpoint: scenario.input.endpointUrl, httpStack: testStack, - metadata: scenario.input.metadata ?? {}, onError, onSuccess(payload) { if (scenarioWantsEvent(scenario, 'success')) { @@ -541,68 +577,10 @@ async function startScenarioUpload(scenario, testStack) { } } - if (scenario.input.chunkSize != null) { - options.chunkSize = scenario.input.chunkSize - } - - if (scenario.input.metadataForPartialUploads != null) { - options.metadataForPartialUploads = scenario.input.metadataForPartialUploads - } - - if (scenario.input.headers != null) { - options.headers = scenario.input.headers - } - - if (scenario.input.addRequestId != null) { - options.addRequestId = scenario.input.addRequestId - } - - if (scenario.input.overridePatchMethod != null) { - options.overridePatchMethod = scenario.input.overridePatchMethod - } - - if (scenario.input.parallelUploads != null) { - options.parallelUploads = scenario.input.parallelUploads - } - - if (scenario.input.parallelUploadBoundaries != null) { - options.parallelUploadBoundaries = scenario.input.parallelUploadBoundaries - } - - if (scenario.input.retryDelays != null) { - options.retryDelays = scenario.input.retryDelays - } - - if (scenario.input.protocol != null) { - options.protocol = scenario.input.protocol - } - - if (scenario.input.uploadSize != null) { - options.uploadSize = scenario.input.uploadSize - } - - if (scenario.input.removeFingerprintOnSuccess != null) { - options.removeFingerprintOnSuccess = scenario.input.removeFingerprintOnSuccess - } - - if (scenario.input.storeFingerprintForResuming != null) { - options.storeFingerprintForResuming = scenario.input.storeFingerprintForResuming - } - - if (scenario.input.uploadDataDuringCreation != null) { - options.uploadDataDuringCreation = scenario.input.uploadDataDuringCreation + for (const entry of scenario.inputOptionEntries) { + applyScenarioInputOption(options, entry) } - if (scenario.input.uploadLengthDeferred != null) { - options.uploadLengthDeferred = scenario.input.uploadLengthDeferred - } - - if (scenario.input.uploadUrl != null) { - options.uploadUrl = scenario.input.uploadUrl - } - - Object.assign(options, scenario.input.rawOptions ?? {}) - const scenarioFingerprint = scenario.input.fingerprint !== undefined ? scenario.input.fingerprint From 412da2f6480dbecd029920dfbca0a0c5c5072a4d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:57:49 +0200 Subject: [PATCH 119/155] Use generated TUS runtime setup facts --- test/spec/generated-protocol-contract.js | 650 ++++++++++++++++++ test/spec/test-generated-protocol-contract.js | 32 +- 2 files changed, 661 insertions(+), 21 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 8ad403bd8..8f1d6d16f 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2169,6 +2169,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: 'contract-single-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: true, + storedUpload: null, + }, + }, scenarioId: 'singleUploadLifecycle', }, { @@ -2266,6 +2283,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'creationWithUpload', }, { @@ -2449,6 +2483,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'creationWithUploadPartialChunk', }, { @@ -2550,6 +2601,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'ietfDraft05CreationWithUpload', }, { @@ -2762,6 +2830,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'ietfDraft05ChunkedUploadComplete', }, { @@ -2891,6 +2976,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'ietfDraft03ResumeWithoutKnownLength', }, { @@ -2927,6 +3029,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationMissingInput', }, { @@ -2957,6 +3076,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationMissingEndpointOrUploadUrl', }, { @@ -2998,6 +3134,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationUnsupportedProtocol', }, { @@ -3043,6 +3196,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationRetryDelaysNotArray', }, { @@ -3089,6 +3259,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationParallelUploadsWithUploadUrl', }, { @@ -3135,6 +3322,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationParallelUploadsWithUploadSize', }, { @@ -3182,6 +3386,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationParallelUploadsWithDeferredLength', }, { @@ -3229,6 +3450,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', }, { @@ -3281,6 +3519,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', }, { @@ -3338,6 +3593,23 @@ export const tusClientConformanceScenarios = [ primitives: ['validate-start-options'], requests: [], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'startValidationParallelBoundariesLengthMismatch', }, { @@ -3434,6 +3706,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'detailedCreateResponseError', }, { @@ -3523,6 +3812,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'detailedCreateRequestError', }, { @@ -3641,6 +3947,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'uploadBodyHeaders', }, { @@ -3774,6 +4097,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'customRequestHeaders', }, { @@ -3909,6 +4249,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: true, + generatedRequestId: '00000000-0000-4000-8000-000000000000', + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'requestIdHeaders', }, { @@ -4061,6 +4418,27 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: 'contract-resume-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: true, + storedUpload: { + fingerprint: 'contract-resume-fingerprint', + uploadUrl: 'https://tus.io/uploads/resume-contract', + urlStorageKey: 'tus::contract-resume-fingerprint::1337', + }, + }, + }, scenarioId: 'resumeFromPreviousUpload', }, { @@ -4188,6 +4566,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'relativeLocationResolution', }, { @@ -4306,6 +4701,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'arrayBufferInput', }, { @@ -4424,6 +4836,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'arrayBufferViewInput', }, { @@ -4553,6 +4982,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'webReadableStreamInput', }, { @@ -4682,6 +5128,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'nodeReadableStreamInput', runtimes: ['node'], }, @@ -4801,6 +5264,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'nodePathInput', runtimes: ['node'], }, @@ -4941,6 +5421,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'deferredLengthUpload', }, { @@ -5173,6 +5670,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'deferredLengthChunkedUpload', }, { @@ -5293,6 +5807,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'overridePatchMethod', }, { @@ -5570,6 +6101,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'parallelUploadConcat', }, { @@ -5868,6 +6416,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: true, + }, + fingerprint: { + install: true, + value: 'contract-parallel-cleanup-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'parallelUploadAbortCleanup', }, { @@ -6146,6 +6711,23 @@ export const tusClientConformanceScenarios = [ retryAttempt: 0, }, ], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'retryPatchAfterOffsetRecovery', }, { @@ -6223,6 +6805,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'requestLifecycleHooks', }, { @@ -6302,6 +6901,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'abortUpload', }, { @@ -6473,6 +7089,23 @@ export const tusClientConformanceScenarios = [ }, ], retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: true, + }, + fingerprint: { + install: true, + value: 'contract-abort-terminate-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'abortUploadAfterStoredUrl', }, { @@ -6674,6 +7307,23 @@ export const tusClientConformanceScenarios = [ retryAttempt: 0, }, ], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, scenarioId: 'terminateWithRetry', }, ] diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 0fd622f84..7d6bac0f1 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -247,14 +247,15 @@ function applyScenarioInputOption(options, entry) { } function installGeneratedRequestIdRandom(scenario) { - if (!scenario.input.addRequestId) { + const requestIdSetup = scenario.runtimeSetup.requestId + if (!requestIdSetup.enabled) { return () => {} } const expectedZeroUuid = '00000000-0000-4000-8000-000000000000' - if (scenario.input.generatedRequestId !== expectedZeroUuid) { + if (requestIdSetup.generatedRequestId !== expectedZeroUuid) { throw new Error( - `Generated scenario ${scenario.scenarioId} has unsupported generatedRequestId ${scenario.input.generatedRequestId}`, + `Generated scenario ${scenario.scenarioId} has unsupported generatedRequestId ${requestIdSetup.generatedRequestId}`, ) } @@ -482,7 +483,7 @@ async function abortScenarioRequest(req, scenario, request, requestIndex, observ return originalAbort() } - const abortPromise = upload.abort(Boolean(scenario.input.terminateUploadOnAbort)) + const abortPromise = upload.abort(scenario.runtimeSetup.abort.terminateUpload) await wait(0) expect(req.method).toBe(request.effectiveMethod ?? request.method ?? operation.method) @@ -581,17 +582,10 @@ async function startScenarioUpload(scenario, testStack) { applyScenarioInputOption(options, entry) } - const scenarioFingerprint = - scenario.input.fingerprint !== undefined - ? scenario.input.fingerprint - : scenario.input.storedUpload?.fingerprint - if ( - scenarioFingerprint !== undefined || - scenario.input.kind === 'web-readable-stream' || - scenario.input.kind === 'node-readable-stream' - ) { + const fingerprintSetup = scenario.runtimeSetup.fingerprint + if (fingerprintSetup.install) { options.fingerprint = jasmine.createSpy('fingerprint').and.callFake(() => { - const fingerprint = scenarioFingerprint ?? null + const fingerprint = fingerprintSetup.value if (scenarioWantsEvent(scenario, 'fingerprint')) { observedEvents.push({ fingerprint, kind: 'fingerprint' }) } @@ -599,13 +593,9 @@ async function startScenarioUpload(scenario, testStack) { }) } - if ( - scenario.input.storedUpload != null || - scenarioWantsEvent(scenario, 'url-storage-add') || - scenarioWantsEvent(scenario, 'url-storage-find') || - scenarioWantsEvent(scenario, 'url-storage-remove') - ) { - options.urlStorage = makeEventRecordingUrlStorage(scenario.input.storedUpload, observedEvents) + const urlStorageSetup = scenario.runtimeSetup.urlStorage + if (urlStorageSetup.install) { + options.urlStorage = makeEventRecordingUrlStorage(urlStorageSetup.storedUpload, observedEvents) } const onChunkCompleteActions = scenarioExecutionActions(scenario, 'onChunkComplete') From 66925024db802890f61fcf99f5f43e0e2fb74f16 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 17:01:50 +0200 Subject: [PATCH 120/155] Drop raw input from TUS generated fixtures --- test/spec/generated-protocol-contract.js | 355 ----------------------- 1 file changed, 355 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 8f1d6d16f..b5c5f773a 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2060,15 +2060,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'singleUploadLifecycle', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - fingerprint: 'contract-single-fingerprint', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -2211,15 +2202,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'creationWithUpload', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - uploadDataDuringCreation: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -2332,16 +2314,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'creationWithUpload', - input: { - chunkSize: 5, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - uploadDataDuringCreation: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -2525,16 +2497,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'protocolVersionSelection', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - protocol: 'ietf-draft-05', - uploadDataDuringCreation: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -2650,14 +2612,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'protocolVersionSelection', - input: { - chunkSize: 5, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - protocol: 'ietf-draft-05', - uploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -2873,14 +2827,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'protocolVersionSelection', - input: { - chunkSize: 6, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - protocol: 'ietf-draft-03', - uploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3010,11 +2956,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: '', - endpointUrl: 'https://tus.io/uploads', - kind: 'none', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3063,10 +3004,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - kind: 'blob', - }, inputOptionEntries: [], inputSource: { content: 'hello world', @@ -3110,12 +3047,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - protocol: 'tus-v9', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3168,14 +3099,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - rawOptions: { - retryDelays: 44, - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3230,13 +3153,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - parallelUploads: 2, - uploadUrl: 'https://tus.io/uploads/start-validation-upload-url', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3293,13 +3209,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - parallelUploads: 2, - uploadSize: 11, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3357,13 +3266,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - parallelUploads: 2, - uploadLengthDeferred: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3421,13 +3323,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - parallelUploads: 2, - uploadDataDuringCreation: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3485,17 +3380,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - parallelUploadBoundaries: [ - { - end: 5, - start: 0, - }, - ], - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3554,18 +3438,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'startOptionValidation', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - parallelUploadBoundaries: [ - { - end: 5, - start: 0, - }, - ], - parallelUploads: 2, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3628,20 +3500,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'detailedErrors', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - headers: { - 'X-Request-ID': 'contract-request-id', - }, - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - rawOptions: { - retryDelays: null, - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3741,20 +3599,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'detailedErrors', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - headers: { - 'X-Request-ID': 'contract-request-id', - }, - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - rawOptions: { - retryDelays: null, - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3846,14 +3690,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'uploadBodyHeaders', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -3981,18 +3817,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'customRequestHeaders', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - headers: { - 'X-Tus-Contract': 'custom-header', - 'X-Tus-Trace': 'trace-123', - }, - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -4131,19 +3955,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'requestIdHeaders', - input: { - addRequestId: true, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - generatedRequestId: '00000000-0000-4000-8000-000000000000', - headers: { - 'X-Request-ID': 'custom-request-id', - }, - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -4316,17 +4127,6 @@ export const tusClientConformanceScenarios = [ }, ], featureId: 'resumeUpload', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - removeFingerprintOnSuccess: true, - storedUpload: { - fingerprint: 'contract-resume-fingerprint', - uploadUrl: 'https://tus.io/uploads/resume-contract', - urlStorageKey: 'tus::contract-resume-fingerprint::1337', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -4465,14 +4265,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'relativeLocationResolution', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/files/', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -4600,14 +4392,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'inputSources', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'array-buffer', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -4735,14 +4519,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'inputSources', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'array-buffer-view', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -4870,16 +4646,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'inputSources', - input: { - chunkSize: 100, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'web-readable-stream', - metadata: { - filename: 'hello.txt', - }, - uploadLengthDeferred: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -5016,16 +4782,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'inputSources', - input: { - chunkSize: 100, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'node-readable-stream', - metadata: { - filename: 'hello.txt', - }, - uploadLengthDeferred: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -5163,14 +4919,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'inputSources', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'node-path-reference', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -5309,16 +5057,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'deferredLengthUpload', - input: { - chunkSize: 100, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'web-readable-stream', - metadata: { - filename: 'hello.txt', - }, - uploadLengthDeferred: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -5484,16 +5222,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'deferredLengthUpload', - input: { - chunkSize: 5, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - uploadLengthDeferred: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -5704,13 +5432,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'overridePatchMethod', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - overridePatchMethod: true, - uploadUrl: 'https://tus.io/uploads/override-contract', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -5861,18 +5582,6 @@ export const tusClientConformanceScenarios = [ }, ], featureId: 'parallelUploadConcat', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - foo: 'hello', - }, - metadataForPartialUploads: { - test: 'world', - }, - parallelUploads: 2, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -6148,22 +5857,6 @@ export const tusClientConformanceScenarios = [ }, ], featureId: 'parallelUploadConcat', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - fingerprint: 'contract-parallel-cleanup-fingerprint', - headers: { - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', - }, - kind: 'blob', - metadataForPartialUploads: { - test: 'world', - }, - overridePatchMethod: true, - parallelUploads: 2, - terminateUploadOnAbort: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -6455,15 +6148,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'retryOffsetRecovery', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - retryDelays: [0], - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -6745,12 +6429,6 @@ export const tusClientConformanceScenarios = [ }, executionActionPhases: [], featureId: 'requestLifecycleHooks', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - uploadUrl: 'https://tus.io/uploads/request-hooks-contract', - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -6849,14 +6527,6 @@ export const tusClientConformanceScenarios = [ }, ], featureId: 'abortUpload', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -6945,21 +6615,6 @@ export const tusClientConformanceScenarios = [ }, ], featureId: 'abortUpload', - input: { - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - fingerprint: 'contract-abort-terminate-fingerprint', - headers: { - 'X-Tus-Contract': 'abort-policy', - 'X-Tus-Trace': 'abort-trace-123', - }, - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - overridePatchMethod: true, - terminateUploadOnAbort: true, - }, inputOptionEntries: [ { key: 'endpointUrl', @@ -7133,16 +6788,6 @@ export const tusClientConformanceScenarios = [ }, ], featureId: 'terminateUpload', - input: { - chunkSize: 5, - content: 'hello world', - endpointUrl: 'https://tus.io/uploads', - kind: 'blob', - metadata: { - filename: 'hello.txt', - }, - retryDelays: [0, 0], - }, inputOptionEntries: [ { key: 'endpointUrl', From 8842cfcabb0f593d14d997ab400f42e28db52487 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 22:27:01 +0200 Subject: [PATCH 121/155] Regenerate TUS protocol runtime operation IDs --- lib/protocol_generated.ts | 28 +++++++++++---- test/spec/generated-protocol-contract.js | 44 +++++++++++++++++++++--- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 6be9abbc1..05e4956ae 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -241,6 +241,11 @@ export const TUS_FLOW_POLICY = { removeStoredUrl: 'before-hook-when-option-enabled', }, uploadUrlAvailable: { + contexts: { + createUpload: 'createUpload', + parallelFinalUpload: 'parallelFinalUpload', + resumeUpload: 'resumeUpload', + }, createUpload: 'after-url-known-before-storage', parallelFinalUpload: 'not-emitted', resumeUpload: 'after-url-known-before-storage', @@ -287,6 +292,10 @@ export const TUS_FLOW_POLICY = { separator: '-', }, nodeFile: { + conformanceFixture: { + absolutePath: '/tmp/tus-contract-file.bin', + mtimeMs: 1700000000123, + }, fields: ['prefix', 'absolutePath', 'size', 'mtimeMs', 'endpoint'], path: 'absolute', prefix: 'node-file', @@ -460,6 +469,13 @@ export const TUS_FLOW_POLICY = { namespace: 'tus', record: { creationTime: 'sdk-current-date-string', + fields: { + creationTime: 'creationTime', + metadata: 'metadata', + size: 'size', + uploadUrl: 'uploadUrl', + urlStorageKey: 'urlStorageKey', + }, missingUrl: 'fail', storedUrlKind: 'single-or-parallel-upload-url', }, @@ -2746,7 +2762,7 @@ export function tusShouldRetryStatus(status: number): boolean { } export function tusPlanTerminateResponse({ status }: { status: number }): TusTerminateResponsePlan { - if (tusExpectedResponseStatusForOperation(tusOperationIdForRole('termination'), status)) { + if (tusExpectedResponseStatusForOperation(TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, status)) { return { action: 'complete' } } @@ -2981,7 +2997,7 @@ export function tusCreateUploadRequestPlan({ ? {} : tusUploadCompleteHeaders({ done: uploadComplete, protocol })), }, - operationId: tusOperationIdForRole('creation'), + operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, protocol, url: endpoint, }) @@ -3003,7 +3019,7 @@ export function tusFinalUploadRequestPlan({ metadata, uploadUrls, }), - operationId: tusOperationIdForRole('creation'), + operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, protocol, url: endpoint, }) @@ -3017,7 +3033,7 @@ export function tusGetUploadOffsetRequestPlan({ uploadUrl: string }): TusRequestPlan { return tusRequestPlanForOperation({ - operationId: tusOperationIdForRole('offset-discovery'), + operationId: TUS_OPERATION_IDS.GET_TUS_UPLOAD_OFFSET, protocol, url: uploadUrl, }) @@ -3034,7 +3050,7 @@ export function tusPatchUploadRequestPlan({ protocol: string uploadUrl: string }): TusRequestPlan { - const operationId = tusOperationIdForRole('upload-chunk') + const operationId = TUS_OPERATION_IDS.PATCH_TUS_UPLOAD const methodOverride = overridePatchMethod ? tusMethodOverrideForOperation(operationId) : undefined @@ -3062,7 +3078,7 @@ export function tusTerminateUploadRequestPlan({ uploadUrl: string }): TusRequestPlan { return tusRequestPlanForOperation({ - operationId: tusOperationIdForRole('termination'), + operationId: TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, protocol, url: uploadUrl, }) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index b5c5f773a..c281d61f4 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2178,6 +2178,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'singleUploadLifecycle', + runtimes: [], }, { behavior: 'creation-with-upload', @@ -2283,6 +2284,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'creationWithUpload', + runtimes: [], }, { behavior: 'creation-with-upload-partial-chunk', @@ -2473,6 +2475,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'creationWithUploadPartialChunk', + runtimes: [], }, { behavior: 'creation-with-upload', @@ -2581,6 +2584,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'ietfDraft05CreationWithUpload', + runtimes: [], }, { behavior: 'upload-body-headers', @@ -2802,6 +2806,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'ietfDraft05ChunkedUploadComplete', + runtimes: [], }, { behavior: 'upload-body-headers', @@ -2940,6 +2945,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'ietfDraft03ResumeWithoutKnownLength', + runtimes: [], }, { behavior: 'start-option-validation', @@ -2988,6 +2994,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationMissingInput', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3031,6 +3038,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationMissingEndpointOrUploadUrl', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3083,6 +3091,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationUnsupportedProtocol', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3137,6 +3146,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationRetryDelaysNotArray', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3193,6 +3203,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithUploadUrl', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3249,6 +3260,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithUploadSize', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3306,6 +3318,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithDeferredLength', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3363,6 +3376,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3421,6 +3435,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', + runtimes: [], }, { behavior: 'start-option-validation', @@ -3483,6 +3498,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelBoundariesLengthMismatch', + runtimes: [], }, { behavior: 'detailed-error', @@ -3582,6 +3598,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'detailedCreateResponseError', + runtimes: [], }, { behavior: 'detailed-error', @@ -3674,6 +3691,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'detailedCreateRequestError', + runtimes: [], }, { behavior: 'upload-body-headers', @@ -3801,6 +3819,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'uploadBodyHeaders', + runtimes: [], }, { behavior: 'custom-request-headers', @@ -3926,8 +3945,8 @@ export const tusClientConformanceScenarios = [ terminateUpload: false, }, fingerprint: { - install: false, - value: null, + install: true, + value: 'contract-custom-headers-fingerprint', }, requestId: { enabled: false, @@ -3939,6 +3958,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'customRequestHeaders', + runtimes: [], }, { behavior: 'request-id-headers', @@ -4078,6 +4098,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'requestIdHeaders', + runtimes: [], }, { behavior: 'resume-from-previous-upload', @@ -4240,6 +4261,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'resumeFromPreviousUpload', + runtimes: [], }, { behavior: 'relative-location-resolution', @@ -4376,6 +4398,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'relativeLocationResolution', + runtimes: [], }, { behavior: 'array-buffer-input', @@ -4503,6 +4526,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'arrayBufferInput', + runtimes: [], }, { behavior: 'array-buffer-view-input', @@ -4630,6 +4654,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'arrayBufferViewInput', + runtimes: [], }, { behavior: 'web-readable-stream-input', @@ -4766,6 +4791,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'webReadableStreamInput', + runtimes: [], }, { behavior: 'node-readable-stream-input', @@ -5177,6 +5203,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'deferredLengthUpload', + runtimes: [], }, { behavior: 'deferred-length-upload', @@ -5416,6 +5443,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'deferredLengthChunkedUpload', + runtimes: [], }, { behavior: 'override-patch-method', @@ -5533,8 +5561,8 @@ export const tusClientConformanceScenarios = [ terminateUpload: false, }, fingerprint: { - install: false, - value: null, + install: true, + value: 'contract-override-fingerprint', }, requestId: { enabled: false, @@ -5546,6 +5574,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'overridePatchMethod', + runtimes: [], }, { behavior: 'parallel-upload-concat', @@ -5828,6 +5857,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'parallelUploadConcat', + runtimes: [], }, { behavior: 'parallel-upload-abort-cleanup', @@ -6127,6 +6157,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'parallelUploadAbortCleanup', + runtimes: [], }, { behavior: 'retry-patch-after-offset-recovery', @@ -6413,6 +6444,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'retryPatchAfterOffsetRecovery', + runtimes: [], }, { behavior: 'request-lifecycle-hooks', @@ -6501,6 +6533,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'requestLifecycleHooks', + runtimes: [], }, { behavior: 'abort-upload', @@ -6589,6 +6622,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'abortUpload', + runtimes: [], }, { behavior: 'abort-upload-after-stored-url', @@ -6762,6 +6796,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'abortUploadAfterStoredUrl', + runtimes: [], }, { behavior: 'terminate-with-retry', @@ -6970,6 +7005,7 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'terminateWithRetry', + runtimes: [], }, ] From 04911bac5875621e6b5d3520ec928c37dee362ad Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 11:13:21 +0200 Subject: [PATCH 122/155] Regenerate TUS protocol fixture --- test/spec/generated-protocol-contract.js | 38 ++---------------------- 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index c281d61f4..4764286fb 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -1185,6 +1185,7 @@ export const tusManagedUpload = { scheduler: 'durable-os-scheduler', sourceDurability: ['copy-to-owned-storage', 'reference-original-source'], stateBackend: 'platform-key-value-store', + transportProfileId: 'java-http-url-connection', }, { networkConstraints: ['any-network', 'unmetered-network'], @@ -1206,6 +1207,7 @@ export const tusManagedUpload = { scheduler: 'process-lifetime-worker-pool', sourceDurability: ['copy-to-owned-storage', 'reference-original-source'], stateBackend: 'filesystem', + transportProfileId: 'java-http-url-connection', }, { networkConstraints: ['any-network'], @@ -2178,7 +2180,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'singleUploadLifecycle', - runtimes: [], }, { behavior: 'creation-with-upload', @@ -2284,7 +2285,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'creationWithUpload', - runtimes: [], }, { behavior: 'creation-with-upload-partial-chunk', @@ -2475,7 +2475,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'creationWithUploadPartialChunk', - runtimes: [], }, { behavior: 'creation-with-upload', @@ -2584,7 +2583,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'ietfDraft05CreationWithUpload', - runtimes: [], }, { behavior: 'upload-body-headers', @@ -2806,7 +2804,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'ietfDraft05ChunkedUploadComplete', - runtimes: [], }, { behavior: 'upload-body-headers', @@ -2945,7 +2942,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'ietfDraft03ResumeWithoutKnownLength', - runtimes: [], }, { behavior: 'start-option-validation', @@ -2994,7 +2990,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationMissingInput', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3038,7 +3033,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationMissingEndpointOrUploadUrl', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3091,7 +3085,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationUnsupportedProtocol', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3146,7 +3139,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationRetryDelaysNotArray', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3203,7 +3195,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithUploadUrl', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3260,7 +3251,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithUploadSize', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3318,7 +3308,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithDeferredLength', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3376,7 +3365,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelUploadsWithUploadDataDuringCreation', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3435,7 +3423,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelBoundariesWithoutParallelUploads', - runtimes: [], }, { behavior: 'start-option-validation', @@ -3498,7 +3485,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'startValidationParallelBoundariesLengthMismatch', - runtimes: [], }, { behavior: 'detailed-error', @@ -3598,7 +3584,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'detailedCreateResponseError', - runtimes: [], }, { behavior: 'detailed-error', @@ -3691,7 +3676,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'detailedCreateRequestError', - runtimes: [], }, { behavior: 'upload-body-headers', @@ -3819,7 +3803,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'uploadBodyHeaders', - runtimes: [], }, { behavior: 'custom-request-headers', @@ -3958,7 +3941,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'customRequestHeaders', - runtimes: [], }, { behavior: 'request-id-headers', @@ -4098,7 +4080,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'requestIdHeaders', - runtimes: [], }, { behavior: 'resume-from-previous-upload', @@ -4261,7 +4242,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'resumeFromPreviousUpload', - runtimes: [], }, { behavior: 'relative-location-resolution', @@ -4398,7 +4378,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'relativeLocationResolution', - runtimes: [], }, { behavior: 'array-buffer-input', @@ -4526,7 +4505,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'arrayBufferInput', - runtimes: [], }, { behavior: 'array-buffer-view-input', @@ -4654,7 +4632,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'arrayBufferViewInput', - runtimes: [], }, { behavior: 'web-readable-stream-input', @@ -4791,7 +4768,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'webReadableStreamInput', - runtimes: [], }, { behavior: 'node-readable-stream-input', @@ -5203,7 +5179,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'deferredLengthUpload', - runtimes: [], }, { behavior: 'deferred-length-upload', @@ -5443,7 +5418,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'deferredLengthChunkedUpload', - runtimes: [], }, { behavior: 'override-patch-method', @@ -5574,7 +5548,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'overridePatchMethod', - runtimes: [], }, { behavior: 'parallel-upload-concat', @@ -5857,7 +5830,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'parallelUploadConcat', - runtimes: [], }, { behavior: 'parallel-upload-abort-cleanup', @@ -6157,7 +6129,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'parallelUploadAbortCleanup', - runtimes: [], }, { behavior: 'retry-patch-after-offset-recovery', @@ -6444,7 +6415,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'retryPatchAfterOffsetRecovery', - runtimes: [], }, { behavior: 'request-lifecycle-hooks', @@ -6533,7 +6503,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'requestLifecycleHooks', - runtimes: [], }, { behavior: 'abort-upload', @@ -6622,7 +6591,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'abortUpload', - runtimes: [], }, { behavior: 'abort-upload-after-stored-url', @@ -6796,7 +6764,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'abortUploadAfterStoredUrl', - runtimes: [], }, { behavior: 'terminate-with-retry', @@ -7005,7 +6972,6 @@ export const tusClientConformanceScenarios = [ }, }, scenarioId: 'terminateWithRetry', - runtimes: [], }, ] From fb220f4387989bea4b26771085921bb56c83572b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 19:46:43 +0200 Subject: [PATCH 123/155] Drop generated TUS operation role lookup --- lib/protocol_generated.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 05e4956ae..771961535 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -45,15 +45,6 @@ export const TUS_OPERATION_METHOD_BY_ID: Record = { downloadTusUpload: 'GET', } -export const TUS_OPERATION_ID_BY_ROLE: Record = { - 'capability-discovery': 'discoverTusCapabilities', - creation: 'createTusUpload', - 'offset-discovery': 'getTusUploadOffset', - 'upload-chunk': 'patchTusUpload', - termination: 'terminateTusUpload', - download: 'downloadTusUpload', -} - export const TUS_OPERATION_IDS = { DISCOVER_TUS_CAPABILITIES: 'discoverTusCapabilities', CREATE_TUS_UPLOAD: 'createTusUpload', @@ -2780,15 +2771,6 @@ export function tusExpectedResponseStatusForOperation( return TUS_OPERATION_RESPONSE_STATUS_CODES[operationId]?.includes(status) ?? false } -export function tusOperationIdForRole(role: string): string { - const operationId = TUS_OPERATION_ID_BY_ROLE[role] - if (operationId == null) { - throw new Error(`Unknown TUS operation role: ${role}`) - } - - return operationId -} - export function tusRequiresKnownUploadLengthOnOffsetResponse(protocol: string): boolean { return TUS_PROTOCOLS_REQUIRING_KNOWN_UPLOAD_LENGTH_ON_OFFSET_RESPONSE.includes(protocol) } From 6ef0df589a1b35b68834e0696f0471f2f78d9944 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 6 Jun 2026 23:41:41 +0200 Subject: [PATCH 124/155] Add API2 resume upload example --- examples/api2-devdock-shared/scenario.js | 150 ++++++++++++++++++ .../main.js | 142 ++--------------- .../api2-devdock-tus-resume-upload/main.js | 148 +++++++++++++++++ 3 files changed, 312 insertions(+), 128 deletions(-) create mode 100644 examples/api2-devdock-shared/scenario.js create mode 100644 examples/api2-devdock-tus-resume-upload/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js new file mode 100644 index 000000000..ee48d8378 --- /dev/null +++ b/examples/api2-devdock-shared/scenario.js @@ -0,0 +1,150 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export function fail(message) { + throw new Error(message) +} + +function exampleDirname(moduleUrl) { + return path.dirname(fileURLToPath(moduleUrl)) +} + +export async function loadScenario(moduleUrl) { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? + path.join(exampleDirname(moduleUrl), 'api2-scenario.json') + + return JSON.parse(await readFile(scenarioPath, 'utf8')) +} + +export 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 +} + +export 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) +} + +export function scalarString(value) { + if (value === null) { + return 'null' + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + return String(value) +} + +export 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') +} + +export 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 +} + +export function retryDelays(retries) { + if (!Number.isInteger(retries) || retries < 0) { + fail(`unsupported retry count ${JSON.stringify(retries)}`) + } + + return Array.from({ length: retries }, () => 0) +} + +export function chunkSizeBytes(chunkSize, contentLength) { + if (chunkSize === 'full-file') { + return contentLength + } + + if ( + typeof chunkSize === 'object' && + chunkSize !== null && + !Array.isArray(chunkSize) && + chunkSize.kind === 'fixed-bytes' && + Number.isInteger(chunkSize.bytes) && + chunkSize.bytes > 0 + ) { + return chunkSize.bytes + } + + fail(`unsupported chunk size policy ${JSON.stringify(chunkSize)}`) +} + +export function requireResumePlan(uploadConfig) { + const resume = uploadConfig.resume + if (typeof resume !== 'object' || resume === null || Array.isArray(resume)) { + fail('scenario upload is missing a resume plan') + } + + return resume +} + +export async function writeJsonResult(result) { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.js b/examples/api2-devdock-transloadit-assembly-upload/main.js index 09397b42d..8b3bdaa55 100644 --- a/examples/api2-devdock-transloadit-assembly-upload/main.js +++ b/examples/api2-devdock-transloadit-assembly-upload/main.js @@ -1,130 +1,25 @@ -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) -} +import { + chunkSizeBytes, + fail, + loadScenario, + resolveValue, + retryDelays, + scalarString, + scenarioBytes, + uploadMetadata, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' 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, + chunkSize: chunkSizeBytes(uploadConfig.chunkSize, content.length), metadata: uploadMetadata(uploadConfig, scenario, createResponse), retryDelays: retryDelays(uploadConfig.retries), }) @@ -142,20 +37,11 @@ async function uploadWithTus(scenario, createResponse) { 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 scenario = await loadScenario(import.meta.url) const createResponse = scenario.prepared.createResponse const uploadUrl = await uploadWithTus(scenario, createResponse) - await writeResult(uploadUrl) + await writeJsonResult({ uploadUrl }) console.log(`TypeScript TUS SDK devdock scenario ${scenario.scenarioId} uploaded to ${uploadUrl}`) } diff --git a/examples/api2-devdock-tus-resume-upload/main.js b/examples/api2-devdock-tus-resume-upload/main.js new file mode 100644 index 000000000..9cfcab4ac --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.js @@ -0,0 +1,148 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { FileUrlStorage } from '../../lib.esm/node/FileUrlStorage.js' +import { Upload } from '../../lib.esm/node/index.js' +import { + chunkSizeBytes, + fail, + loadScenario, + requireResumePlan, + resolveValue, + retryDelays, + scalarString, + scenarioBytes, + uploadMetadata, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function uploadOptions({ content, createResponse, scenario, storage }) { + const uploadConfig = scenario.upload + const resume = requireResumePlan(uploadConfig) + const context = { createResponse, scenario } + + return { + endpoint: scalarString(resolveValue(uploadConfig.tusUrl, context, 'tusUrl')), + chunkSize: chunkSizeBytes(uploadConfig.chunkSize, content.length), + fingerprint: async () => resume.fingerprint, + metadata: uploadMetadata(uploadConfig, scenario, createResponse), + retryDelays: retryDelays(uploadConfig.retries), + urlStorage: storage, + } +} + +async function uploadFirstChunkAndAbort({ content, createResponse, scenario, storage }) { + const resume = requireResumePlan(scenario.upload) + let firstUploadUrl = null + let acceptedBytes = 0 + let upload = null + + await new Promise((resolve, reject) => { + upload = new Upload(content, { + ...uploadOptions({ content, createResponse, scenario, storage }), + onChunkComplete(_chunkSize, bytesAccepted) { + acceptedBytes = bytesAccepted + if (bytesAccepted < resume.stopAfterAcceptedBytes) { + return + } + + firstUploadUrl = upload.url + void upload.abort().then(resolve, reject) + }, + onError: reject, + onSuccess() { + reject(new Error('resume scenario completed before the first upload was aborted')) + }, + }) + upload.start() + }) + + if (!firstUploadUrl) { + fail('resume scenario did not capture the first upload URL') + } + + return { acceptedBytes, firstUploadUrl } +} + +async function resumeStoredUpload({ content, createResponse, scenario, storage }) { + const resume = requireResumePlan(scenario.upload) + const upload = new Upload(content, uploadOptions({ content, createResponse, scenario, storage })) + const previousUploads = await upload.findPreviousUploads() + if (previousUploads.length !== resume.expectedPreviousUploadCount) { + fail( + `resume scenario expected ${resume.expectedPreviousUploadCount} stored upload(s), got ${previousUploads.length}`, + ) + } + + const previousUpload = previousUploads[0] + if (!previousUpload) { + fail('resume scenario could not find a previous upload') + } + + upload.resumeFromPreviousUpload(previousUpload) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('resumed TUS upload did not expose an upload URL') + } + + const remainingPreviousUploads = await upload.findPreviousUploads() + if (remainingPreviousUploads.length !== resume.expectedRemainingPreviousUploadCount) { + fail( + `resume scenario expected ${resume.expectedRemainingPreviousUploadCount} stored upload(s) after success, got ${remainingPreviousUploads.length}`, + ) + } + + return { + previousUploadCount: previousUploads.length, + remainingPreviousUploadCount: remainingPreviousUploads.length, + uploadUrl: upload.url, + } +} + +async function uploadWithStoredResume(scenario, createResponse) { + const content = scenarioBytes(scenario.upload) + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'api2-tus-resume-')) + const storagePath = path.join(tempDir, 'url-storage.json') + await writeFile(storagePath, '{}\n') + + try { + const storage = new FileUrlStorage(storagePath) + const firstUpload = await uploadFirstChunkAndAbort({ + content, + createResponse, + scenario, + storage, + }) + const resumedUpload = await resumeStoredUpload({ content, createResponse, scenario, storage }) + + return { + firstAcceptedBytes: firstUpload.acceptedBytes, + firstUploadUrl: firstUpload.firstUploadUrl, + ...resumedUpload, + } + } finally { + await rm(tempDir, { force: true, recursive: true }) + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithStoredResume(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} resumed ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 87ce4b4580c82cb50503260c0d27cada3f92920a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 6 Jun 2026 23:46:07 +0200 Subject: [PATCH 125/155] Refresh generated protocol contract --- lib/protocol_generated.ts | 68 ++++++- test/spec/generated-protocol-contract.js | 246 ++++++++++++++++++----- 2 files changed, 256 insertions(+), 58 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 771961535..96349173f 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -480,6 +480,20 @@ export const TUS_FLOW_POLICY = { }, } +export const TUS_SUCCESS_CLOSE_SOURCE_AFTER_HOOK = true + +export const TUS_SUCCESS_CLOSE_SOURCE_REQUIRES_SOURCE = true + +export const TUS_SUCCESS_EMIT_AFTER_UPLOAD_COMPLETE = true + +export const TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK = true + +export const TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION = true + +export const TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED = true + +export const TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION = true + export type TusNumericHeaderReadResult = | { ok: false; reason: 'invalid' | 'missing' } | { ok: true; value: number } @@ -1556,12 +1570,24 @@ export function tusPlanSuccessEvent({ tusAssertEventHookPolicySupported() return { - closeSource: hasSource, + closeSource: tusShouldCloseSourceOnSuccess({ hasSource }), removeStoredUpload: tusShouldRemoveStoredUploadOnSuccess({ removeFingerprintOnSuccess }), shouldCall: hasHook, } } +export function tusShouldCloseSourceOnSuccess({ hasSource }: { hasSource: boolean }): boolean { + tusAssertEventHookPolicySupported() + + if (!TUS_SUCCESS_CLOSE_SOURCE_AFTER_HOOK) { + return false + } + if (TUS_SUCCESS_CLOSE_SOURCE_REQUIRES_SOURCE) { + return hasSource + } + return true +} + export function tusCommonSupportedFileSourceTypes(): readonly string[] { return [...TUS_FLOW_POLICY.fileSources.commonTypes] } @@ -2067,9 +2093,22 @@ export function tusShouldRemoveStoredUploadOnSuccess({ }: { removeFingerprintOnSuccess: boolean }): boolean { + tusAssertEventHookPolicySupported() tusAssertUrlStorageCleanupPolicySupported() - return removeFingerprintOnSuccess + if (!TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK) { + return false + } + if (!TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED) { + return false + } + if ( + TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION || + TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION + ) { + return removeFingerprintOnSuccess + } + return true } export function tusUrlStorageCreationTime({ now }: { now: Date }): string { @@ -2597,7 +2636,25 @@ export function tusShouldResetRetryAttempt({ }): boolean { tusAssertRequestLifecyclePolicySupported() - return offset > offsetBeforeRetry + const policy = TUS_FLOW_POLICY.requestLifecycle.retry.attemptCounter.reset + switch (policy) { + case 'when-offset-advanced-since-last-retry': + return offset > offsetBeforeRetry + default: + throw new Error(`tus: unsupported retry reset policy ${policy}`) + } +} + +export function tusNextRetryAttempt({ retryAttempt }: { retryAttempt: number }): number { + tusAssertRequestLifecyclePolicySupported() + + const policy = TUS_FLOW_POLICY.requestLifecycle.retry.attemptCounter.increment + switch (policy) { + case 'after-retry-scheduled': + return retryAttempt + 1 + default: + throw new Error(`tus: unsupported retry increment policy ${policy}`) + } } export function tusPlanRetryAfterError({ @@ -2631,12 +2688,13 @@ export function tusPlanRetryAfterError({ return { action: 'emitError', retryAttempt: effectiveRetryAttempt } } + const nextRetryAttempt = tusNextRetryAttempt({ retryAttempt: effectiveRetryAttempt }) return { action: 'scheduleRetry', delay: retryDelays[effectiveRetryAttempt], - nextRetryAttempt: effectiveRetryAttempt + 1, + nextRetryAttempt, offsetBeforeRetry: offset, - remainingRetryDelays: [...retryDelays.slice(effectiveRetryAttempt + 1)], + remainingRetryDelays: [...retryDelays.slice(nextRetryAttempt)], retryAttempt: effectiveRetryAttempt, } } diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 4764286fb..0a11cff21 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -2094,8 +2094,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -2130,6 +2133,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -2233,8 +2237,12 @@ export const tusClientConformanceScenarios = [ bodySize: 11, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -2349,8 +2357,12 @@ export const tusClientConformanceScenarios = [ bodySize: 5, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -2388,6 +2400,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, headersSpecified: true, @@ -2425,6 +2438,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, headersSpecified: true, @@ -2532,8 +2546,13 @@ export const tusClientConformanceScenarios = [ bodySize: 11, errorMessage: null, headerMode: 'exact', - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -2559,6 +2578,7 @@ export const tusClientConformanceScenarios = [ 'Upload-Length': '11', 'Upload-Complete': '?1', 'Content-Type': 'application/partial-upload', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads', @@ -2680,6 +2700,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: 'exact', headers: { + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', 'Upload-Offset': '0', }, headersSpecified: true, @@ -2717,6 +2739,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: 'exact', headers: { + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', 'Upload-Offset': '5', }, headersSpecified: true, @@ -2754,6 +2778,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: 'exact', headers: { + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', 'Upload-Offset': '10', }, headersSpecified: true, @@ -2893,6 +2919,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: 'exact', headers: { + 'Upload-Complete': '?1', 'Upload-Offset': '5', }, headersSpecified: true, @@ -3539,8 +3566,12 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': 'contract-request-id', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -3638,8 +3669,12 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: 'socket down', headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': 'contract-request-id', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: null, @@ -3717,8 +3752,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -3753,6 +3791,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -3851,8 +3890,13 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -3889,7 +3933,10 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', }, headersSpecified: true, method: null, @@ -3992,8 +4039,12 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4029,7 +4080,9 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', }, headersSpecified: true, method: null, @@ -4188,6 +4241,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, headersSpecified: true, @@ -4292,8 +4346,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4328,6 +4385,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -4419,8 +4477,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4455,6 +4516,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -4546,8 +4608,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4582,6 +4647,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -4681,8 +4747,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4717,6 +4786,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -4817,8 +4888,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4853,6 +4927,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -4946,8 +5022,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -4982,6 +5061,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -5092,8 +5172,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -5128,6 +5211,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -5257,8 +5342,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -5293,6 +5381,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -5330,6 +5419,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, headersSpecified: true, @@ -5367,6 +5457,8 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, headersSpecified: true, @@ -5497,6 +5589,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '3', }, headersSpecified: true, @@ -5626,6 +5719,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '5', }, @@ -5651,9 +5745,9 @@ export const tusClientConformanceScenarios = [ requestIndex: 0, effectiveHeaders: { 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'Upload-Metadata': 'test d29ybGQ=', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads', @@ -5665,6 +5759,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '6', }, @@ -5690,9 +5785,9 @@ export const tusClientConformanceScenarios = [ requestIndex: 1, effectiveHeaders: { 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'Upload-Metadata': 'test d29ybGQ=', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads', @@ -5704,6 +5799,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -5741,6 +5837,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -5778,6 +5875,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Metadata': 'foo aGVsbG8=', 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', }, @@ -5803,9 +5901,9 @@ export const tusClientConformanceScenarios = [ requestIndex: 4, effectiveHeaders: { 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'foo aGVsbG8=', 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', - 'Upload-Metadata': 'foo aGVsbG8=', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads', @@ -5907,8 +6005,11 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '5', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -5932,9 +6033,9 @@ export const tusClientConformanceScenarios = [ requestIndex: 0, effectiveHeaders: { 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'Upload-Metadata': 'test d29ybGQ=', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, @@ -5948,8 +6049,11 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '6', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -5973,9 +6077,9 @@ export const tusClientConformanceScenarios = [ requestIndex: 1, effectiveHeaders: { 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'Upload-Metadata': 'test d29ybGQ=', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, @@ -5989,7 +6093,10 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -6010,9 +6117,9 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', + 'X-HTTP-Method-Override': 'PATCH', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', @@ -6024,7 +6131,10 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, headersSpecified: true, method: null, @@ -6038,9 +6148,9 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', + 'X-HTTP-Method-Override': 'PATCH', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', @@ -6051,7 +6161,10 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, + headers: { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, headersSpecified: true, method: null, operationId: 'terminateTusUpload', @@ -6083,7 +6196,10 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, + headers: { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, headersSpecified: true, method: null, operationId: 'terminateTusUpload', @@ -6186,8 +6302,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -6222,6 +6341,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, @@ -6289,6 +6409,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, headersSpecified: true, @@ -6356,6 +6477,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, headersSpecified: true, @@ -6554,8 +6676,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: null, @@ -6653,8 +6778,13 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -6691,7 +6821,10 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', }, headersSpecified: true, method: null, @@ -6705,9 +6838,9 @@ export const tusClientConformanceScenarios = [ 'Tus-Resumable': '1.0.0', 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', + 'X-HTTP-Method-Override': 'PATCH', }, effectiveMethod: 'POST', expectedUrl: 'https://tus.io/uploads/abort-terminate-contract', @@ -6718,7 +6851,10 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, + headers: { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, headersSpecified: true, method: null, operationId: 'terminateTusUpload', @@ -6823,8 +6959,11 @@ export const tusClientConformanceScenarios = [ bodySize: null, errorMessage: null, headerMode: null, - headers: {}, - headersSpecified: false, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, method: null, operationId: 'createTusUpload', response: { @@ -6859,6 +6998,7 @@ export const tusClientConformanceScenarios = [ errorMessage: null, headerMode: null, headers: { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, headersSpecified: true, From c5b2c2dc590424c3b0e714303687f495a1bfb25a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 6 Jun 2026 23:52:32 +0200 Subject: [PATCH 126/155] Use scenario cleanup policy in resume example --- examples/api2-devdock-tus-resume-upload/main.js | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/api2-devdock-tus-resume-upload/main.js b/examples/api2-devdock-tus-resume-upload/main.js index 9cfcab4ac..a1ac47bba 100644 --- a/examples/api2-devdock-tus-resume-upload/main.js +++ b/examples/api2-devdock-tus-resume-upload/main.js @@ -27,6 +27,7 @@ function uploadOptions({ content, createResponse, scenario, storage }) { chunkSize: chunkSizeBytes(uploadConfig.chunkSize, content.length), fingerprint: async () => resume.fingerprint, metadata: uploadMetadata(uploadConfig, scenario, createResponse), + removeFingerprintOnSuccess: resume.removeFingerprintOnSuccess, retryDelays: retryDelays(uploadConfig.retries), urlStorage: storage, } From e788d1ff99f4b8b74f0b6f34c50f4681bb445b81 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:07:47 +0200 Subject: [PATCH 127/155] Add creation-with-upload devdock example --- examples/api2-devdock-shared/scenario.js | 17 ++++++++ .../main.js | 18 ++------ .../main.js | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 examples/api2-devdock-tus-creation-with-upload/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index ee48d8378..80b1f8517 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -104,6 +104,23 @@ export function uploadMetadata(uploadConfig, scenario, createResponse) { return metadata } +export function tusUploadOptions({ content, createResponse, scenario }) { + const uploadConfig = scenario.upload + const context = { createResponse, scenario } + const options = { + endpoint: scalarString(resolveValue(uploadConfig.tusUrl, context, 'tusUrl')), + chunkSize: chunkSizeBytes(uploadConfig.chunkSize, content.length), + metadata: uploadMetadata(uploadConfig, scenario, createResponse), + retryDelays: retryDelays(uploadConfig.retries), + } + + if (uploadConfig.uploadDataDuringCreation === true) { + options.uploadDataDuringCreation = true + } + + return options +} + export function retryDelays(retries) { if (!Number.isInteger(retries) || retries < 0) { fail(`unsupported retry count ${JSON.stringify(retries)}`) diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.js b/examples/api2-devdock-transloadit-assembly-upload/main.js index 8b3bdaa55..ab52f0a7d 100644 --- a/examples/api2-devdock-transloadit-assembly-upload/main.js +++ b/examples/api2-devdock-transloadit-assembly-upload/main.js @@ -1,28 +1,16 @@ import { Upload } from '../../lib.esm/node/index.js' import { - chunkSizeBytes, fail, loadScenario, - resolveValue, - retryDelays, - scalarString, scenarioBytes, - uploadMetadata, + tusUploadOptions, writeJsonResult, } from '../api2-devdock-shared/scenario.js' 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) + const content = scenarioBytes(scenario.upload) - const upload = new Upload(content, { - endpoint, - chunkSize: chunkSizeBytes(uploadConfig.chunkSize, content.length), - metadata: uploadMetadata(uploadConfig, scenario, createResponse), - retryDelays: retryDelays(uploadConfig.retries), - }) + const upload = new Upload(content, tusUploadOptions({ content, createResponse, scenario })) await new Promise((resolve, reject) => { upload.options.onError = reject diff --git a/examples/api2-devdock-tus-creation-with-upload/main.js b/examples/api2-devdock-tus-creation-with-upload/main.js new file mode 100644 index 000000000..caf99153e --- /dev/null +++ b/examples/api2-devdock-tus-creation-with-upload/main.js @@ -0,0 +1,42 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithCreationData(scenario, createResponse) { + if (scenario.upload.uploadDataDuringCreation !== true) { + fail('creation-with-upload scenario must set uploadDataDuringCreation') + } + + const content = scenarioBytes(scenario.upload) + const upload = new Upload(content, tusUploadOptions({ content, createResponse, scenario })) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('creation-with-upload TUS upload did not expose an upload URL') + } + + return upload.url +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const uploadUrl = await uploadWithCreationData(scenario, createResponse) + await writeJsonResult({ uploadUrl }) + console.log(`TypeScript TUS SDK devdock scenario ${scenario.scenarioId} uploaded to ${uploadUrl}`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 7fcb6d7c91c699a2efbd3a5c6ba5ec331325ed18 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:22:41 +0200 Subject: [PATCH 128/155] Add retry offset recovery devdock example --- examples/api2-devdock-shared/scenario.js | 13 ++ .../main.js | 130 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 examples/api2-devdock-tus-retry-offset-recovery/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 80b1f8517..679fde3a5 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -157,6 +157,19 @@ export function requireResumePlan(uploadConfig) { return resume } +export function requireRetryOffsetRecoveryPlan(uploadConfig) { + const retryOffsetRecovery = uploadConfig.retryOffsetRecovery + if ( + typeof retryOffsetRecovery !== 'object' || + retryOffsetRecovery === null || + Array.isArray(retryOffsetRecovery) + ) { + fail('scenario upload is missing a retry offset recovery plan') + } + + return retryOffsetRecovery +} + export async function writeJsonResult(result) { const resultPath = process.env.API2_SDK_EXAMPLE_RESULT if (!resultPath) { diff --git a/examples/api2-devdock-tus-retry-offset-recovery/main.js b/examples/api2-devdock-tus-retry-offset-recovery/main.js new file mode 100644 index 000000000..272cac6a5 --- /dev/null +++ b/examples/api2-devdock-tus-retry-offset-recovery/main.js @@ -0,0 +1,130 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireRetryOffsetRecoveryPlan, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function assertRequestMethods(actual, expected) { + if (!Array.isArray(expected)) { + fail('retry offset recovery scenario expectedRequestMethods must be an array') + } + + if (actual.length !== expected.length) { + fail( + `retry offset recovery expected request methods ${expected.join(',')}, got ${actual.join(',')}`, + ) + } + + for (const [index, method] of expected.entries()) { + if (actual[index] !== method) { + fail( + `retry offset recovery expected request method ${method} at index ${index}, got ${actual[index]}`, + ) + } + } +} + +function readOffsetHeader(res, headerName) { + const value = res.getHeader(headerName) + const offset = Number(value) + if (!Number.isInteger(offset) || offset < 0) { + fail(`retry offset recovery expected numeric ${headerName} response header, got ${value}`) + } + + return offset +} + +async function uploadWithRetryOffsetRecovery(scenario, createResponse) { + const retryOffsetRecovery = requireRetryOffsetRecoveryPlan(scenario.upload) + const content = scenarioBytes(scenario.upload) + const recoveredOffsets = [] + const requestMethods = [] + let failureCandidateCount = 0 + let simulatedFailureCount = 0 + + const upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onAfterResponse(req, res) { + const method = req.getMethod() + + if (method === retryOffsetRecovery.recoveryResponse.method) { + recoveredOffsets.push( + readOffsetHeader(res, retryOffsetRecovery.recoveryResponse.offsetHeader), + ) + } + + if (method !== retryOffsetRecovery.failAfterResponse.method) { + return + } + + failureCandidateCount += 1 + if (failureCandidateCount !== retryOffsetRecovery.failAfterResponse.occurrence) { + return + } + + simulatedFailureCount += 1 + throw new Error(retryOffsetRecovery.failAfterResponse.message) + }, + onBeforeRequest(req) { + requestMethods.push(req.getMethod()) + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('retry offset recovery TUS upload did not expose an upload URL') + } + + if (simulatedFailureCount !== retryOffsetRecovery.expectedFailureCount) { + fail( + `retry offset recovery expected ${retryOffsetRecovery.expectedFailureCount} simulated failure(s), got ${simulatedFailureCount}`, + ) + } + + if (recoveredOffsets.length !== retryOffsetRecovery.expectedRecoveryRequestCount) { + fail( + `retry offset recovery expected ${retryOffsetRecovery.expectedRecoveryRequestCount} recovery request(s), got ${recoveredOffsets.length}`, + ) + } + + const recoveredOffset = recoveredOffsets[0] + if (recoveredOffset !== retryOffsetRecovery.expectedRecoveredOffset) { + fail( + `retry offset recovery expected recovered offset ${retryOffsetRecovery.expectedRecoveredOffset}, got ${recoveredOffset}`, + ) + } + + assertRequestMethods(requestMethods, retryOffsetRecovery.expectedRequestMethods) + + return { + recoveredOffsets, + recoveryRequestCount: recoveredOffsets.length, + requestMethods, + simulatedFailureCount, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithRetryOffsetRecovery(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} recovered offset for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 10af11adb745a7ae6438c1583638c369a8ffacc4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:39:01 +0200 Subject: [PATCH 129/155] Regenerate TUS protocol response fixtures --- lib/protocol_generated.ts | 9 +- test/spec/generated-protocol-contract.js | 134 ++++++++++++++++++++++- 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 96349173f..4b37d24e1 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -84,18 +84,21 @@ export const TUS_REQUEST_CONTENT_TYPES = { export const TUS_RESPONSE_STATUS_CODES = { DISCOVER_TUS_CAPABILITIES_200: 200, CREATE_TUS_UPLOAD_201: 201, + CREATE_TUS_UPLOAD_500: 500, GET_TUS_UPLOAD_OFFSET_200: 200, PATCH_TUS_UPLOAD_204: 204, + PATCH_TUS_UPLOAD_500: 500, TERMINATE_TUS_UPLOAD_204: 204, + TERMINATE_TUS_UPLOAD_423: 423, DOWNLOAD_TUS_UPLOAD_200: 200, } as const export const TUS_OPERATION_RESPONSE_STATUS_CODES: Record = { discoverTusCapabilities: [200], - createTusUpload: [201], + createTusUpload: [201, 500], getTusUploadOffset: [200], - patchTusUpload: [204], - terminateTusUpload: [204], + patchTusUpload: [204, 500], + terminateTusUpload: [204, 423], downloadTusUpload: [200], } diff --git a/test/spec/generated-protocol-contract.js b/test/spec/generated-protocol-contract.js index 0a11cff21..8c7fa1a3e 100644 --- a/test/spec/generated-protocol-contract.js +++ b/test/spec/generated-protocol-contract.js @@ -166,6 +166,21 @@ export const tusProtocolOperations = [ }, ], }, + { + statusCode: 500, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, ], }, { @@ -286,6 +301,21 @@ export const tusProtocolOperations = [ }, ], }, + { + statusCode: 500, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, ], }, { @@ -324,6 +354,21 @@ export const tusProtocolOperations = [ }, ], }, + { + statusCode: 423, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, ], }, { @@ -2092,6 +2137,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -2130,6 +2176,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -2235,6 +2282,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -2355,6 +2403,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -2397,6 +2446,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -2435,6 +2485,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 1, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -2544,6 +2595,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Tus-Resumable'], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: { @@ -2663,6 +2715,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Tus-Resumable'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: {}, @@ -2697,6 +2750,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Tus-Resumable'], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: { @@ -2736,6 +2790,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Tus-Resumable'], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: { @@ -2775,6 +2830,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Tus-Resumable'], abort: false, bodySize: 1, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: { @@ -2884,6 +2940,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Tus-Resumable'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: {}, @@ -2916,6 +2973,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Content-Type', 'Tus-Resumable'], abort: false, bodySize: 6, + bodyStart: null, errorMessage: null, headerMode: 'exact', headers: { @@ -3564,6 +3622,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -3580,7 +3639,9 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, - effectiveHeaders: {}, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: null, uploadUrl: null, @@ -3667,6 +3728,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: 'socket down', headerMode: null, headers: { @@ -3750,6 +3812,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -3788,6 +3851,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -3888,6 +3952,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -3930,6 +3995,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4037,6 +4103,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4077,6 +4144,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4203,6 +4271,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, @@ -4238,6 +4307,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 6, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4344,6 +4414,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4382,6 +4453,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4475,6 +4547,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4513,6 +4586,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4606,6 +4680,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4644,6 +4719,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4745,6 +4821,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Upload-Length'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4783,6 +4860,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4886,6 +4964,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Upload-Length'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -4924,6 +5003,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5020,6 +5100,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5058,6 +5139,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5170,6 +5252,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Upload-Length'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5208,6 +5291,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5340,6 +5424,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Upload-Length'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5378,6 +5463,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: 0, errorMessage: null, headerMode: null, headers: { @@ -5416,6 +5502,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5454,6 +5541,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 1, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5551,6 +5639,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, @@ -5586,6 +5675,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 8, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5716,6 +5806,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5756,6 +5847,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5796,6 +5888,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -5834,6 +5927,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 6, + bodyStart: 5, errorMessage: null, headerMode: null, headers: { @@ -5872,6 +5966,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: ['Upload-Length'], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6002,6 +6097,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6046,6 +6142,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6090,6 +6187,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: 0, errorMessage: null, headerMode: null, headers: { @@ -6107,7 +6205,9 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, - effectiveHeaders: {}, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-partial-chunk', uploadUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', @@ -6128,6 +6228,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: true, bodySize: 6, + bodyStart: 5, errorMessage: null, headerMode: null, headers: { @@ -6159,6 +6260,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6194,6 +6296,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6300,6 +6403,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6338,6 +6442,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6353,7 +6458,9 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, - effectiveHeaders: {}, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'upload-chunk', uploadUrl: null, @@ -6371,6 +6478,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, @@ -6406,6 +6514,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 6, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6421,7 +6530,9 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 500, - effectiveHeaders: {}, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'retry-upload-chunk', uploadUrl: null, @@ -6439,6 +6550,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, @@ -6474,6 +6586,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 6, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6574,6 +6687,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, @@ -6674,6 +6788,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: true, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6776,6 +6891,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6818,6 +6934,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: true, bodySize: 11, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6849,6 +6966,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6957,6 +7075,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -6995,6 +7114,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: 5, + bodyStart: null, errorMessage: null, headerMode: null, headers: { @@ -7033,6 +7153,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, @@ -7045,7 +7166,9 @@ export const tusClientConformanceScenarios = [ headers: {}, headersSpecified: false, statusCode: 423, - effectiveHeaders: {}, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, }, role: 'terminate-upload', uploadUrl: null, @@ -7061,6 +7184,7 @@ export const tusClientConformanceScenarios = [ absentHeaders: [], abort: false, bodySize: null, + bodyStart: null, errorMessage: null, headerMode: null, headers: {}, From 32449036cc05db246a872d4cce9e09d59569cb26 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:53:10 +0200 Subject: [PATCH 130/155] Regenerate TUS termination success semantics --- lib/protocol_generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index 4b37d24e1..b249fc65e 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -2814,7 +2814,7 @@ export function tusShouldRetryStatus(status: number): boolean { } export function tusPlanTerminateResponse({ status }: { status: number }): TusTerminateResponsePlan { - if (tusExpectedResponseStatusForOperation(TUS_OPERATION_IDS.TERMINATE_TUS_UPLOAD, status)) { + if (tusIsSuccessfulResponseStatus(status)) { return { action: 'complete' } } From ec2593733ccc449c912d5c7be08793ea615c3f16 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 01:05:12 +0200 Subject: [PATCH 131/155] Add termination devdock example --- examples/api2-devdock-shared/scenario.js | 18 ++++ .../api2-devdock-tus-terminate-upload/main.js | 86 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 examples/api2-devdock-tus-terminate-upload/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 679fde3a5..4d6c44094 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -2,6 +2,11 @@ import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { + TUS_DEFAULT_CLIENT_PROTOCOL, + tusRequestHeadersForProtocol, +} from '../../lib.esm/protocol_generated.js' + export function fail(message) { throw new Error(message) } @@ -121,6 +126,10 @@ export function tusUploadOptions({ content, createResponse, scenario }) { return options } +export function tusDefaultRequestHeaders() { + return tusRequestHeadersForProtocol(TUS_DEFAULT_CLIENT_PROTOCOL) +} + export function retryDelays(retries) { if (!Number.isInteger(retries) || retries < 0) { fail(`unsupported retry count ${JSON.stringify(retries)}`) @@ -170,6 +179,15 @@ export function requireRetryOffsetRecoveryPlan(uploadConfig) { return retryOffsetRecovery } +export function requireTerminationPlan(uploadConfig) { + const termination = uploadConfig.termination + if (typeof termination !== 'object' || termination === null || Array.isArray(termination)) { + fail('scenario upload is missing a termination plan') + } + + return termination +} + export async function writeJsonResult(result) { const resultPath = process.env.API2_SDK_EXAMPLE_RESULT if (!resultPath) { diff --git a/examples/api2-devdock-tus-terminate-upload/main.js b/examples/api2-devdock-tus-terminate-upload/main.js new file mode 100644 index 000000000..69a03ba81 --- /dev/null +++ b/examples/api2-devdock-tus-terminate-upload/main.js @@ -0,0 +1,86 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTerminationPlan, + scenarioBytes, + tusDefaultRequestHeaders, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function countRequests(methods, method) { + return methods.filter((candidate) => candidate === method).length +} + +async function verifyTerminatedUpload({ termination, uploadUrl }) { + const response = await fetch(uploadUrl, { + headers: tusDefaultRequestHeaders(), + method: termination.verificationMethod, + }) + + return response.status +} + +async function uploadAndTerminate(scenario, createResponse) { + const termination = requireTerminationPlan(scenario.upload) + const content = scenarioBytes(scenario.upload) + const requestMethods = [] + let acceptedBytes = 0 + let upload = null + let uploadUrl = null + + await new Promise((resolve, reject) => { + upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onBeforeRequest(req) { + requestMethods.push(req.getMethod()) + }, + onChunkComplete(_chunkSize, bytesAccepted) { + acceptedBytes = bytesAccepted + if (bytesAccepted < termination.stopAfterAcceptedBytes) { + return + } + + uploadUrl = upload.url + void upload.abort(true).then(resolve, reject) + }, + onError: reject, + onSuccess() { + reject(new Error('termination scenario completed before abort(true) terminated the upload')) + }, + }) + upload.start() + }) + + if (!uploadUrl) { + fail('termination scenario did not capture the upload URL before abort(true)') + } + + requestMethods.push(termination.verificationMethod) + const verificationStatus = await verifyTerminatedUpload({ termination, uploadUrl }) + + return { + acceptedBytes, + deleteRequestCount: countRequests(requestMethods, 'DELETE'), + requestMethods, + terminated: true, + uploadUrl, + verificationStatus, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadAndTerminate(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} terminated ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From a9615bb939489d2ed4597e94459a07e99a6e423e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 09:41:40 +0200 Subject: [PATCH 132/155] Add API2 upload callback proof --- examples/api2-devdock-shared/scenario.js | 76 +++++++++++ .../api2-devdock-tus-upload-callbacks/main.js | 129 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 examples/api2-devdock-tus-upload-callbacks/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 4d6c44094..8d544bce9 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -188,6 +188,82 @@ export function requireTerminationPlan(uploadConfig) { return termination } +export function requireUploadCallbacksPlan(uploadConfig) { + const callbacks = uploadConfig.uploadCallbacks + if (typeof callbacks !== 'object' || callbacks === null || Array.isArray(callbacks)) { + fail('scenario upload is missing an upload callback plan') + } + + return callbacks +} + +export function uploadCallbackEventKey(callbacks, ...parts) { + return parts.join(callbacks.eventKeyPartSeparator) +} + +export function uploadCallbackEventKeyNumber(value) { + return String(value) +} + +export function uploadCallbackEventKeyTotal(value) { + return scalarString(value) +} + +function uploadCallbackEventMatchesExpected(callbacks, expectedIndex, actual) { + if (actual === callbacks.eventKeys[expectedIndex]) { + return true + } + + if (expectedIndex >= callbacks.eventKeyAlternativeGroups.length) { + return false + } + + return callbacks.eventKeyAlternativeGroups[expectedIndex].includes(actual) +} + +function hasAllowedUploadCallbackExtraEventPrefix(callbacks, event) { + return callbacks.allowedExtraEventKeyPrefixes.some((prefix) => event.startsWith(prefix)) +} + +export function matchUploadCallbackEventKeys(callbacks, actual) { + const policy = callbacks.eventPolicyMatching + if (policy !== 'exact' && policy !== 'exact-except-allowed-extra-events') { + fail(`unsupported upload callback event policy ${JSON.stringify(policy)}`) + } + + let expectedIndex = 0 + const matched = [] + for (const event of actual) { + if ( + expectedIndex < callbacks.eventKeys.length && + uploadCallbackEventMatchesExpected(callbacks, expectedIndex, event) + ) { + matched.push(callbacks.eventKeys[expectedIndex]) + expectedIndex += 1 + continue + } + + if ( + policy === 'exact-except-allowed-extra-events' && + hasAllowedUploadCallbackExtraEventPrefix(callbacks, event) + ) { + continue + } + + fail( + `upload callback events emitted unexpected extra event ${JSON.stringify(event)}; allowed prefixes ${JSON.stringify(callbacks.allowedExtraEventKeyPrefixes)}; expected ${JSON.stringify(callbacks.eventKeys)}, got ${JSON.stringify(actual)}`, + ) + } + + if (expectedIndex !== callbacks.eventKeys.length) { + fail( + `upload callback events did not emit every expected non-extra event; expected ${JSON.stringify(callbacks.eventKeys)}, got ${JSON.stringify(actual)}`, + ) + } + + return matched +} + export async function writeJsonResult(result) { const resultPath = process.env.API2_SDK_EXAMPLE_RESULT if (!resultPath) { diff --git a/examples/api2-devdock-tus-upload-callbacks/main.js b/examples/api2-devdock-tus-upload-callbacks/main.js new file mode 100644 index 000000000..37f535071 --- /dev/null +++ b/examples/api2-devdock-tus-upload-callbacks/main.js @@ -0,0 +1,129 @@ +import { Readable } from 'node:stream' + +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + matchUploadCallbackEventKeys, + requireUploadCallbacksPlan, + scenarioBytes, + tusUploadOptions, + uploadCallbackEventKey, + uploadCallbackEventKeyNumber, + uploadCallbackEventKeyTotal, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +class EventRecordingReadable extends Readable { + #callbacks + + #content + + #events + + #sent = false + + constructor(content, callbacks, events) { + super() + this.#callbacks = callbacks + this.#content = content + this.#events = events + } + + _read() { + if (this.#sent) { + this.push(null) + return + } + + this.#sent = true + this.push(this.#content) + } + + _destroy(error, callback) { + this.#events.push( + uploadCallbackEventKey(this.#callbacks, this.#callbacks.eventKinds.sourceClose), + ) + callback(error) + } +} + +async function uploadWithCallbacks(scenario, createResponse) { + const content = scenarioBytes(scenario.upload) + const callbacks = requireUploadCallbacksPlan(scenario.upload) + const events = [] + if (scenario.upload.chunkSize !== 'full-file') { + fail(`unsupported chunk size policy ${JSON.stringify(scenario.upload.chunkSize)}`) + } + + const source = new EventRecordingReadable(content, callbacks, events) + const upload = new Upload(source, { + ...tusUploadOptions({ content, createResponse, scenario }), + uploadSize: content.length, + onChunkComplete(chunkSize, bytesAccepted, bytesTotal) { + events.push( + uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.chunkComplete, + uploadCallbackEventKeyNumber(chunkSize), + uploadCallbackEventKeyNumber(bytesAccepted), + uploadCallbackEventKeyTotal(bytesTotal), + ), + ) + }, + onError: (error) => { + throw error + }, + onProgress(bytesSent, bytesTotal) { + events.push( + uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.progress, + uploadCallbackEventKeyNumber(bytesSent), + uploadCallbackEventKeyTotal(bytesTotal), + ), + ) + }, + onSuccess() { + events.push(uploadCallbackEventKey(callbacks, callbacks.eventKinds.success)) + }, + onUploadUrlAvailable() { + events.push(uploadCallbackEventKey(callbacks, callbacks.eventKinds.uploadUrlAvailable)) + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + const originalOnSuccess = upload.options.onSuccess + upload.options.onSuccess = (payload) => { + originalOnSuccess?.(payload) + resolve() + } + upload.start() + }) + + if (!upload.url) { + fail('upload callbacks TUS upload did not expose an upload URL') + } + + return { + eventKeys: matchUploadCallbackEventKeys(callbacks, events), + rawEventKeys: events, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithCallbacks(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} observed upload callbacks for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 6dca1dd88316c407a81ab0b5733117eea9432bce Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 11:08:06 +0200 Subject: [PATCH 133/155] Add API2 custom request headers proof --- examples/api2-devdock-shared/scenario.js | 4 + .../main.js | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 examples/api2-devdock-tus-custom-request-headers/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 8d544bce9..afadd7ddb 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -119,6 +119,10 @@ export function tusUploadOptions({ content, createResponse, scenario }) { retryDelays: retryDelays(uploadConfig.retries), } + if (uploadConfig.headers) { + options.headers = uploadConfig.headers + } + if (uploadConfig.uploadDataDuringCreation === true) { options.uploadDataDuringCreation = true } diff --git a/examples/api2-devdock-tus-custom-request-headers/main.js b/examples/api2-devdock-tus-custom-request-headers/main.js new file mode 100644 index 000000000..c17b2403d --- /dev/null +++ b/examples/api2-devdock-tus-custom-request-headers/main.js @@ -0,0 +1,80 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function requireUploadHeaders(uploadConfig) { + if ( + typeof uploadConfig.headers !== 'object' || + uploadConfig.headers === null || + Array.isArray(uploadConfig.headers) + ) { + fail('custom request headers scenario is missing upload.headers') + } + + return uploadConfig.headers +} + +function observedCustomHeaders(req, expectedHeaders) { + const headers = {} + for (const headerName of Object.keys(expectedHeaders)) { + const value = req.getHeader(headerName) + if (typeof value !== 'string') { + fail(`custom request headers scenario did not observe ${headerName} on ${req.getMethod()}`) + } + + headers[headerName] = value + } + + return headers +} + +async function uploadWithCustomHeaders(scenario, createResponse) { + const content = scenarioBytes(scenario.upload) + const expectedHeaders = requireUploadHeaders(scenario.upload) + const headersByMethod = {} + + const upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onBeforeRequest(req) { + const method = req.getMethod() + if (method === 'POST' || method === 'PATCH') { + headersByMethod[method] = observedCustomHeaders(req, expectedHeaders) + } + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('custom request headers TUS upload did not expose an upload URL') + } + + return { + headersByMethod, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithCustomHeaders(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} observed custom request headers for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 89402b075b338dab1428495e5c9719ac9e5ef7d8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 11:18:10 +0200 Subject: [PATCH 134/155] Stabilize generated retry timer proof --- test/spec/test-generated-protocol-contract.js | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/test/spec/test-generated-protocol-contract.js b/test/spec/test-generated-protocol-contract.js index 7d6bac0f1..354e30c3b 100644 --- a/test/spec/test-generated-protocol-contract.js +++ b/test/spec/test-generated-protocol-contract.js @@ -499,7 +499,7 @@ async function startScenarioUpload(scenario, testStack) { let beforeRequestIndex = 0 let retryDecisionIndex = 0 const observedEvents = [] - const restoreRetryTimerRecorder = installRetryTimerRecorder(scenario, observedEvents) + const retryTimerRecorder = installRetryTimerRecorder(scenario, observedEvents) const restoreRequestIdRandom = installGeneratedRequestIdRandom(scenario) const onError = waitableFunction('onError') const onSuccess = waitableFunction('onSuccess') @@ -573,6 +573,9 @@ async function startScenarioUpload(scenario, testStack) { kind: 'should-retry', retryAttempt, }) + if (retryDecision.decision) { + retryTimerRecorder.allowNextSchedule() + } retryDecisionIndex += 1 return retryDecision.decision } @@ -641,7 +644,7 @@ async function startScenarioUpload(scenario, testStack) { onError, onSuccess, restoreRequestIdRandom, - restoreRetryTimerRecorder, + restoreRetryTimerRecorder: retryTimerRecorder.restore, terminatePromise: () => terminatePromise, upload, } @@ -649,17 +652,29 @@ async function startScenarioUpload(scenario, testStack) { function installRetryTimerRecorder(scenario, observedEvents) { if (!scenarioWantsEvent(scenario, 'retry-schedule')) { - return () => {} + return { + allowNextSchedule() {}, + restore() {}, + } } const originalSetTimeout = globalThis.setTimeout + let allowedScheduleCount = 0 globalThis.setTimeout = (handler, delay, ...args) => { - observedEvents.push({ delay, kind: 'retry-schedule' }) + if (allowedScheduleCount > 0) { + observedEvents.push({ delay, kind: 'retry-schedule' }) + allowedScheduleCount -= 1 + } return originalSetTimeout(handler, delay, ...args) } - return () => { - globalThis.setTimeout = originalSetTimeout + return { + allowNextSchedule() { + allowedScheduleCount += 1 + }, + restore() { + globalThis.setTimeout = originalSetTimeout + }, } } From f34b8d07967c68b962ba930f134aad26466db7a2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 11:55:10 +0200 Subject: [PATCH 135/155] Add API2 request ID headers proof --- examples/api2-devdock-shared/scenario.js | 4 + .../main.js | 73 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 examples/api2-devdock-tus-request-id-headers/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index afadd7ddb..e9ac80a38 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -123,6 +123,10 @@ export function tusUploadOptions({ content, createResponse, scenario }) { options.headers = uploadConfig.headers } + if (uploadConfig.addRequestId === true) { + options.addRequestId = true + } + if (uploadConfig.uploadDataDuringCreation === true) { options.uploadDataDuringCreation = true } diff --git a/examples/api2-devdock-tus-request-id-headers/main.js b/examples/api2-devdock-tus-request-id-headers/main.js new file mode 100644 index 000000000..622e8474c --- /dev/null +++ b/examples/api2-devdock-tus-request-id-headers/main.js @@ -0,0 +1,73 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function requireRequestIdHeaderName(uploadConfig) { + if (typeof uploadConfig.requestIdHeaderName !== 'string') { + fail('request ID headers scenario is missing upload.requestIdHeaderName') + } + + return uploadConfig.requestIdHeaderName +} + +function observedRequestIdHeader(req, headerName) { + const value = req.getHeader(headerName) + if (typeof value !== 'string') { + fail(`request ID headers scenario did not observe ${headerName} on ${req.getMethod()}`) + } + + return value +} + +async function uploadWithRequestIdHeaders(scenario, createResponse) { + const content = scenarioBytes(scenario.upload) + const requestIdHeaderName = requireRequestIdHeaderName(scenario.upload) + const headersByMethod = {} + + const upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onBeforeRequest(req) { + const method = req.getMethod() + if (method === 'POST' || method === 'PATCH') { + headersByMethod[method] = { + [requestIdHeaderName]: observedRequestIdHeader(req, requestIdHeaderName), + } + } + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('request ID headers TUS upload did not expose an upload URL') + } + + return { + headersByMethod, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithRequestIdHeaders(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} observed request ID headers for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 37e5925335e89ee60d8ad949e26373785442757c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 15:42:41 +0200 Subject: [PATCH 136/155] Add API2 upload body headers proof --- .../main.js | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 examples/api2-devdock-tus-upload-body-headers/main.js diff --git a/examples/api2-devdock-tus-upload-body-headers/main.js b/examples/api2-devdock-tus-upload-body-headers/main.js new file mode 100644 index 000000000..000224857 --- /dev/null +++ b/examples/api2-devdock-tus-upload-body-headers/main.js @@ -0,0 +1,100 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function requireBodyHeadersByMethod(uploadConfig) { + if ( + typeof uploadConfig.bodyHeadersByMethod !== 'object' || + uploadConfig.bodyHeadersByMethod === null || + Array.isArray(uploadConfig.bodyHeadersByMethod) + ) { + fail('upload body headers scenario is missing upload.bodyHeadersByMethod') + } + + return uploadConfig.bodyHeadersByMethod +} + +function bodyHeaderNames(bodyHeadersByMethod) { + const headerNames = new Set() + for (const headers of Object.values(bodyHeadersByMethod)) { + if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) { + fail('upload body headers scenario contains invalid method headers') + } + + for (const headerName of Object.keys(headers)) { + headerNames.add(headerName) + } + } + + return Array.from(headerNames) +} + +function observedBodyHeaders(req, headerNames) { + const headers = {} + for (const headerName of headerNames) { + const value = req.getHeader(headerName) + if (typeof value === 'string') { + headers[headerName] = value + } + } + + return headers +} + +async function uploadWithBodyHeaders(scenario, createResponse) { + const content = scenarioBytes(scenario.upload) + const expectedHeadersByMethod = requireBodyHeadersByMethod(scenario.upload) + const headerNames = bodyHeaderNames(expectedHeadersByMethod) + const bodyHeadersByMethod = {} + + const upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onBeforeRequest(req) { + const method = req.getMethod() + if (method === 'POST' || method === 'PATCH') { + bodyHeadersByMethod[method] = observedBodyHeaders(req, headerNames) + } + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('upload body headers TUS upload did not expose an upload URL') + } + + for (const method of Object.keys(expectedHeadersByMethod)) { + if (!Object.hasOwn(bodyHeadersByMethod, method)) { + fail(`upload body headers scenario did not observe ${method} request`) + } + } + + return { + bodyHeadersByMethod, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithBodyHeaders(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} observed upload body headers for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From fd87d1ab50422c7c4b701f2f973ef902a29b87cc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 20:21:23 +0200 Subject: [PATCH 137/155] Add API2 deferred-length proof --- examples/api2-devdock-shared/scenario.js | 4 + .../main.js | 79 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 examples/api2-devdock-tus-deferred-length-upload/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index e9ac80a38..3351ca93a 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -131,6 +131,10 @@ export function tusUploadOptions({ content, createResponse, scenario }) { options.uploadDataDuringCreation = true } + if (uploadConfig.uploadLengthDeferred === true) { + options.uploadLengthDeferred = true + } + return options } diff --git a/examples/api2-devdock-tus-deferred-length-upload/main.js b/examples/api2-devdock-tus-deferred-length-upload/main.js new file mode 100644 index 000000000..dd4e36920 --- /dev/null +++ b/examples/api2-devdock-tus-deferred-length-upload/main.js @@ -0,0 +1,79 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { TUS_HEADERS } from '../../lib.esm/protocol_generated.js' +import { + fail, + loadScenario, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function observedHeader(req, headerName) { + const value = req.getHeader(headerName) + return typeof value === 'string' ? value : undefined +} + +function assertObservedHeader(headersByMethod, method, headerName, expectedValue) { + const actualValue = headersByMethod[method]?.[headerName] + if (actualValue !== expectedValue) { + fail( + `deferred-length scenario expected ${method} ${headerName}=${expectedValue}, got ${actualValue}`, + ) + } +} + +async function uploadWithDeferredLength(scenario, createResponse) { + if (scenario.upload.uploadLengthDeferred !== true) { + fail('deferred-length scenario must set uploadLengthDeferred') + } + + const content = scenarioBytes(scenario.upload) + const headersByMethod = {} + const upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onBeforeRequest(req) { + const method = req.getMethod() + if (method !== 'POST' && method !== 'PATCH') { + return + } + + headersByMethod[method] = { + [TUS_HEADERS.UPLOAD_DEFER_LENGTH]: observedHeader(req, TUS_HEADERS.UPLOAD_DEFER_LENGTH), + [TUS_HEADERS.UPLOAD_LENGTH]: observedHeader(req, TUS_HEADERS.UPLOAD_LENGTH), + } + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('deferred-length TUS upload did not expose an upload URL') + } + + assertObservedHeader(headersByMethod, 'POST', TUS_HEADERS.UPLOAD_DEFER_LENGTH, '1') + assertObservedHeader(headersByMethod, 'PATCH', TUS_HEADERS.UPLOAD_LENGTH, String(content.length)) + + return { + headersByMethod, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithDeferredLength(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} deferred length for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From fc03d3e627b9afc587d1b7d3115da63901fca38f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 02:59:51 +0200 Subject: [PATCH 138/155] Add API2 request lifecycle proof --- examples/api2-devdock-shared/scenario.js | 13 ++ .../main.js | 115 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 examples/api2-devdock-tus-request-lifecycle-hooks/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 3351ca93a..491b9a45e 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -191,6 +191,19 @@ export function requireRetryOffsetRecoveryPlan(uploadConfig) { return retryOffsetRecovery } +export function requireRequestLifecycleHooksPlan(uploadConfig) { + const requestLifecycleHooks = uploadConfig.requestLifecycleHooks + if ( + typeof requestLifecycleHooks !== 'object' || + requestLifecycleHooks === null || + Array.isArray(requestLifecycleHooks) + ) { + fail('scenario upload is missing a request lifecycle hooks plan') + } + + return requestLifecycleHooks +} + export function requireTerminationPlan(uploadConfig) { const termination = uploadConfig.termination if (typeof termination !== 'object' || termination === null || Array.isArray(termination)) { diff --git a/examples/api2-devdock-tus-request-lifecycle-hooks/main.js b/examples/api2-devdock-tus-request-lifecycle-hooks/main.js new file mode 100644 index 000000000..915a6f5f3 --- /dev/null +++ b/examples/api2-devdock-tus-request-lifecycle-hooks/main.js @@ -0,0 +1,115 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireRequestLifecycleHooksPlan, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function assertArrayEquals(label, actual, expected) { + if (!Array.isArray(expected)) { + fail(`request lifecycle hooks scenario expected ${label} must be an array`) + } + + if (actual.length !== expected.length) { + fail( + `request lifecycle hooks expected ${label} ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ) + } + + for (const [index, value] of expected.entries()) { + if (actual[index] !== value) { + fail( + `request lifecycle hooks expected ${label} value ${JSON.stringify(value)} at index ${index}, got ${JSON.stringify(actual[index])}`, + ) + } + } +} + +function shouldCaptureMethod(method, ignoredMethods) { + return !ignoredMethods.includes(method) +} + +async function uploadWithRequestLifecycleHooks(scenario, createResponse) { + const content = scenarioBytes(scenario.upload) + const requestLifecycleHooks = requireRequestLifecycleHooksPlan(scenario.upload) + const ignoredMethods = requestLifecycleHooks.ignoredRequestMethods + const afterResponseMethods = [] + const afterResponseStatusCodes = [] + const beforeRequestMethods = [] + + if (!Array.isArray(ignoredMethods)) { + fail('request lifecycle hooks scenario ignoredRequestMethods must be an array') + } + + const upload = new Upload(content, { + ...tusUploadOptions({ content, createResponse, scenario }), + onAfterResponse(req, res) { + const method = req.getMethod() + if (!shouldCaptureMethod(method, ignoredMethods)) { + return + } + + afterResponseMethods.push(method) + afterResponseStatusCodes.push(res.getStatus()) + }, + onBeforeRequest(req) { + const method = req.getMethod() + if (!shouldCaptureMethod(method, ignoredMethods)) { + return + } + + beforeRequestMethods.push(method) + }, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('request lifecycle hooks TUS upload did not expose an upload URL') + } + + assertArrayEquals( + 'beforeRequestMethods', + beforeRequestMethods, + requestLifecycleHooks.expectedBeforeRequestMethods, + ) + assertArrayEquals( + 'afterResponseMethods', + afterResponseMethods, + requestLifecycleHooks.expectedAfterResponseMethods, + ) + assertArrayEquals( + 'afterResponseStatusCodes', + afterResponseStatusCodes, + requestLifecycleHooks.expectedAfterResponseStatusCodes, + ) + + return { + afterResponseMethods, + afterResponseStatusCodes, + beforeRequestMethods, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const createResponse = scenario.prepared.createResponse + const result = await uploadWithRequestLifecycleHooks(scenario, createResponse) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} observed request lifecycle hooks for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 158f30478bb6cfc74c3c9d075a441adf65f72c1c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 03:56:25 +0200 Subject: [PATCH 139/155] Add API2 relative Location proof --- examples/api2-devdock-shared/scenario.js | 13 + .../main.js | 224 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 examples/api2-devdock-tus-relative-location-resolution/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 491b9a45e..f3b968b90 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -204,6 +204,19 @@ export function requireRequestLifecycleHooksPlan(uploadConfig) { return requestLifecycleHooks } +export function requireTusConformanceScenario(scenario) { + const conformanceScenario = scenario.conformanceScenario + if ( + typeof conformanceScenario !== 'object' || + conformanceScenario === null || + Array.isArray(conformanceScenario) + ) { + fail('scenario is missing a TUS conformance scenario') + } + + return conformanceScenario +} + export function requireTerminationPlan(uploadConfig) { const termination = uploadConfig.termination if (typeof termination !== 'object' || termination === null || Array.isArray(termination)) { diff --git a/examples/api2-devdock-tus-relative-location-resolution/main.js b/examples/api2-devdock-tus-relative-location-resolution/main.js new file mode 100644 index 000000000..c422778e3 --- /dev/null +++ b/examples/api2-devdock-tus-relative-location-resolution/main.js @@ -0,0 +1,224 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function conformanceInputOptions(conformanceScenario) { + const options = {} + for (const entry of conformanceScenario.inputOptionEntries) { + options[entry.key] = entry.value + } + + return options +} + +function conformanceUploadInput(conformanceScenario) { + const inputSource = conformanceScenario.inputSource + if (inputSource.kind !== 'blob') { + fail(`relative Location scenario cannot build input kind ${JSON.stringify(inputSource.kind)}`) + } + + return new Blob([inputSource.content]) +} + +function bodySize(body) { + if (body == null) { + return null + } + + if (body instanceof Blob) { + return body.size + } + + if (body instanceof ArrayBuffer) { + return body.byteLength + } + + if (ArrayBuffer.isView(body)) { + return body.byteLength + } + + if (typeof body.length === 'number') { + return body.length + } + + fail(`relative Location scenario cannot measure request body ${typeof body}`) +} + +class ContractResponse { + constructor(responsePlan) { + this.responsePlan = responsePlan + } + + getStatus() { + return this.responsePlan.statusCode + } + + getHeader(header) { + return this.responsePlan.effectiveHeaders[header] + } + + getBody() { + return this.responsePlan.body ?? '' + } + + getUnderlyingObject() { + return this.responsePlan + } +} + +class ContractRequest { + constructor({ observed, requestPlan, url }) { + this.headers = {} + this.observed = observed + this.requestPlan = requestPlan + this.url = url + this.progressHandler = () => {} + } + + getMethod() { + return this.requestPlan.effectiveMethod + } + + getURL() { + return this.url + } + + setHeader(header, value) { + this.headers[header] = value + } + + getHeader(header) { + return this.headers[header] + } + + setProgressHandler(progressHandler) { + this.progressHandler = progressHandler + } + + send(body = null) { + const size = bodySize(body) + if (size !== this.requestPlan.bodySize) { + fail( + `relative Location scenario expected request body size ${this.requestPlan.bodySize}, got ${size}`, + ) + } + + for (const [header, value] of Object.entries(this.requestPlan.effectiveHeaders)) { + if (this.headers[header] !== value) { + fail( + `relative Location scenario expected request header ${header}=${JSON.stringify(value)}, got ${JSON.stringify(this.headers[header])}`, + ) + } + } + + if (size != null) { + this.progressHandler(0) + this.progressHandler(size) + } + + this.observed.requestMethods.push(this.requestPlan.effectiveMethod) + this.observed.requestUrls.push(this.url) + + return Promise.resolve(new ContractResponse(this.requestPlan.response)) + } + + abort() { + return Promise.resolve() + } + + getUnderlyingObject() { + return this.requestPlan + } +} + +class ContractHttpStack { + constructor(conformanceScenario) { + this.conformanceScenario = conformanceScenario + this.nextRequestIndex = 0 + this.observed = { + requestMethods: [], + requestUrls: [], + } + } + + createRequest(method, url) { + const requestPlan = this.conformanceScenario.requests[this.nextRequestIndex] + if (!requestPlan) { + fail(`relative Location scenario received unexpected ${method} request to ${url}`) + } + + this.nextRequestIndex += 1 + + if (method !== requestPlan.effectiveMethod) { + fail( + `relative Location scenario expected ${requestPlan.effectiveMethod} request, got ${method}`, + ) + } + + if (url !== requestPlan.expectedUrl) { + fail(`relative Location scenario expected request URL ${requestPlan.expectedUrl}, got ${url}`) + } + + return new ContractRequest({ + observed: this.observed, + requestPlan, + url, + }) + } + + getName() { + return 'API2 contract conformance transport' + } +} + +async function uploadWithRelativeLocationResolution(conformanceScenario) { + const inputOptions = conformanceInputOptions(conformanceScenario) + const content = conformanceUploadInput(conformanceScenario) + const httpStack = new ContractHttpStack(conformanceScenario) + const upload = new Upload(content, { + endpoint: inputOptions.endpointUrl, + httpStack, + metadata: inputOptions.metadata, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('relative Location scenario did not expose an upload URL') + } + + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `relative Location scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadWithRelativeLocationResolution(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} resolved ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From d5fe0f4a3d168736f8d1e8bcf79841ff7ff672de Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 04:17:00 +0200 Subject: [PATCH 140/155] Add API2 override PATCH proof --- examples/api2-devdock-shared/scenario.js | 171 +++++++++++++++++ .../main.js | 60 ++++++ .../main.js | 178 +----------------- 3 files changed, 237 insertions(+), 172 deletions(-) create mode 100644 examples/api2-devdock-tus-override-patch-method/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index f3b968b90..861092b68 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -217,6 +217,177 @@ export function requireTusConformanceScenario(scenario) { return conformanceScenario } +function bodySize(body) { + if (body == null) { + return null + } + + if (body instanceof Blob) { + return body.size + } + + if (body instanceof ArrayBuffer) { + return body.byteLength + } + + if (ArrayBuffer.isView(body)) { + return body.byteLength + } + + if (typeof body.length === 'number') { + return body.length + } + + fail(`TUS conformance scenario cannot measure request body ${typeof body}`) +} + +export function tusConformanceInputOptions(conformanceScenario) { + const options = {} + for (const entry of conformanceScenario.inputOptionEntries) { + options[entry.key] = entry.value + } + + return options +} + +export function tusConformanceUploadInput(conformanceScenario) { + const inputSource = conformanceScenario.inputSource + if (inputSource.kind !== 'blob') { + fail(`TUS conformance scenario cannot build input kind ${JSON.stringify(inputSource.kind)}`) + } + + return new Blob([inputSource.content]) +} + +class ContractResponse { + constructor(responsePlan) { + this.responsePlan = responsePlan + } + + getStatus() { + return this.responsePlan.statusCode + } + + getHeader(header) { + return this.responsePlan.effectiveHeaders[header] + } + + getBody() { + return this.responsePlan.body ?? '' + } + + getUnderlyingObject() { + return this.responsePlan + } +} + +class ContractRequest { + constructor({ observed, requestPlan, url }) { + this.headers = {} + this.observed = observed + this.requestPlan = requestPlan + this.url = url + this.progressHandler = () => {} + } + + getMethod() { + return this.requestPlan.effectiveMethod + } + + getURL() { + return this.url + } + + setHeader(header, value) { + this.headers[header] = value + } + + getHeader(header) { + return this.headers[header] + } + + setProgressHandler(progressHandler) { + this.progressHandler = progressHandler + } + + send(body = null) { + const size = bodySize(body) + if (size !== this.requestPlan.bodySize) { + fail( + `TUS conformance scenario expected request body size ${this.requestPlan.bodySize}, got ${size}`, + ) + } + + for (const [header, value] of Object.entries(this.requestPlan.effectiveHeaders)) { + if (this.headers[header] !== value) { + fail( + `TUS conformance scenario expected request header ${header}=${JSON.stringify(value)}, got ${JSON.stringify(this.headers[header])}`, + ) + } + } + + if (size != null) { + this.progressHandler(0) + this.progressHandler(size) + } + + this.observed.requestHeaders.push({ ...this.headers }) + this.observed.requestMethods.push(this.requestPlan.effectiveMethod) + this.observed.requestUrls.push(this.url) + + return Promise.resolve(new ContractResponse(this.requestPlan.response)) + } + + abort() { + return Promise.resolve() + } + + getUnderlyingObject() { + return this.requestPlan + } +} + +export class TusConformanceHttpStack { + constructor(conformanceScenario) { + this.conformanceScenario = conformanceScenario + this.nextRequestIndex = 0 + this.observed = { + requestHeaders: [], + requestMethods: [], + requestUrls: [], + } + } + + createRequest(method, url) { + const requestPlan = this.conformanceScenario.requests[this.nextRequestIndex] + if (!requestPlan) { + fail(`TUS conformance scenario received unexpected ${method} request to ${url}`) + } + + this.nextRequestIndex += 1 + + if (method !== requestPlan.effectiveMethod) { + fail( + `TUS conformance scenario expected ${requestPlan.effectiveMethod} request, got ${method}`, + ) + } + + if (url !== requestPlan.expectedUrl) { + fail(`TUS conformance scenario expected request URL ${requestPlan.expectedUrl}, got ${url}`) + } + + return new ContractRequest({ + observed: this.observed, + requestPlan, + url, + }) + } + + getName() { + return 'API2 contract conformance transport' + } +} + export function requireTerminationPlan(uploadConfig) { const termination = uploadConfig.termination if (typeof termination !== 'object' || termination === null || Array.isArray(termination)) { diff --git a/examples/api2-devdock-tus-override-patch-method/main.js b/examples/api2-devdock-tus-override-patch-method/main.js new file mode 100644 index 000000000..7cdf56d54 --- /dev/null +++ b/examples/api2-devdock-tus-override-patch-method/main.js @@ -0,0 +1,60 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceInputOptions, + tusConformanceUploadInput, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithOverridePatchMethod(conformanceScenario) { + const inputOptions = tusConformanceInputOptions(conformanceScenario) + const content = tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario) + const upload = new Upload(content, { + endpoint: inputOptions.endpointUrl, + httpStack, + overridePatchMethod: inputOptions.overridePatchMethod, + uploadUrl: inputOptions.uploadUrl, + }) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (!upload.url) { + fail('override PATCH scenario did not expose an upload URL') + } + + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `override PATCH scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + requestHeaders: httpStack.observed.requestHeaders, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadWithOverridePatchMethod(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} overrode PATCH for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/api2-devdock-tus-relative-location-resolution/main.js b/examples/api2-devdock-tus-relative-location-resolution/main.js index c422778e3..db698ab63 100644 --- a/examples/api2-devdock-tus-relative-location-resolution/main.js +++ b/examples/api2-devdock-tus-relative-location-resolution/main.js @@ -3,182 +3,16 @@ import { fail, loadScenario, requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceInputOptions, + tusConformanceUploadInput, writeJsonResult, } from '../api2-devdock-shared/scenario.js' -function conformanceInputOptions(conformanceScenario) { - const options = {} - for (const entry of conformanceScenario.inputOptionEntries) { - options[entry.key] = entry.value - } - - return options -} - -function conformanceUploadInput(conformanceScenario) { - const inputSource = conformanceScenario.inputSource - if (inputSource.kind !== 'blob') { - fail(`relative Location scenario cannot build input kind ${JSON.stringify(inputSource.kind)}`) - } - - return new Blob([inputSource.content]) -} - -function bodySize(body) { - if (body == null) { - return null - } - - if (body instanceof Blob) { - return body.size - } - - if (body instanceof ArrayBuffer) { - return body.byteLength - } - - if (ArrayBuffer.isView(body)) { - return body.byteLength - } - - if (typeof body.length === 'number') { - return body.length - } - - fail(`relative Location scenario cannot measure request body ${typeof body}`) -} - -class ContractResponse { - constructor(responsePlan) { - this.responsePlan = responsePlan - } - - getStatus() { - return this.responsePlan.statusCode - } - - getHeader(header) { - return this.responsePlan.effectiveHeaders[header] - } - - getBody() { - return this.responsePlan.body ?? '' - } - - getUnderlyingObject() { - return this.responsePlan - } -} - -class ContractRequest { - constructor({ observed, requestPlan, url }) { - this.headers = {} - this.observed = observed - this.requestPlan = requestPlan - this.url = url - this.progressHandler = () => {} - } - - getMethod() { - return this.requestPlan.effectiveMethod - } - - getURL() { - return this.url - } - - setHeader(header, value) { - this.headers[header] = value - } - - getHeader(header) { - return this.headers[header] - } - - setProgressHandler(progressHandler) { - this.progressHandler = progressHandler - } - - send(body = null) { - const size = bodySize(body) - if (size !== this.requestPlan.bodySize) { - fail( - `relative Location scenario expected request body size ${this.requestPlan.bodySize}, got ${size}`, - ) - } - - for (const [header, value] of Object.entries(this.requestPlan.effectiveHeaders)) { - if (this.headers[header] !== value) { - fail( - `relative Location scenario expected request header ${header}=${JSON.stringify(value)}, got ${JSON.stringify(this.headers[header])}`, - ) - } - } - - if (size != null) { - this.progressHandler(0) - this.progressHandler(size) - } - - this.observed.requestMethods.push(this.requestPlan.effectiveMethod) - this.observed.requestUrls.push(this.url) - - return Promise.resolve(new ContractResponse(this.requestPlan.response)) - } - - abort() { - return Promise.resolve() - } - - getUnderlyingObject() { - return this.requestPlan - } -} - -class ContractHttpStack { - constructor(conformanceScenario) { - this.conformanceScenario = conformanceScenario - this.nextRequestIndex = 0 - this.observed = { - requestMethods: [], - requestUrls: [], - } - } - - createRequest(method, url) { - const requestPlan = this.conformanceScenario.requests[this.nextRequestIndex] - if (!requestPlan) { - fail(`relative Location scenario received unexpected ${method} request to ${url}`) - } - - this.nextRequestIndex += 1 - - if (method !== requestPlan.effectiveMethod) { - fail( - `relative Location scenario expected ${requestPlan.effectiveMethod} request, got ${method}`, - ) - } - - if (url !== requestPlan.expectedUrl) { - fail(`relative Location scenario expected request URL ${requestPlan.expectedUrl}, got ${url}`) - } - - return new ContractRequest({ - observed: this.observed, - requestPlan, - url, - }) - } - - getName() { - return 'API2 contract conformance transport' - } -} - async function uploadWithRelativeLocationResolution(conformanceScenario) { - const inputOptions = conformanceInputOptions(conformanceScenario) - const content = conformanceUploadInput(conformanceScenario) - const httpStack = new ContractHttpStack(conformanceScenario) + const inputOptions = tusConformanceInputOptions(conformanceScenario) + const content = tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario) const upload = new Upload(content, { endpoint: inputOptions.endpointUrl, httpStack, From 717fbdb7002f5d2daf42e00de489c5603a4c91ad Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 04:27:55 +0200 Subject: [PATCH 141/155] Add API2 node path input proof --- examples/api2-devdock-shared/scenario.js | 93 ++++++++++++++++++- .../main.js | 81 ++++++++++++++++ .../main.js | 2 +- .../main.js | 2 +- 4 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 examples/api2-devdock-tus-node-path-input-source/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 861092b68..405806da4 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -1,5 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import path from 'node:path' +import { Readable } from 'node:stream' import { fileURLToPath } from 'node:url' import { @@ -241,6 +243,26 @@ function bodySize(body) { fail(`TUS conformance scenario cannot measure request body ${typeof body}`) } +function contentBytes(content) { + return new TextEncoder().encode(content) +} + +function readableStreamFromContent(content) { + let sent = false + const bytes = contentBytes(content) + return new ReadableStream({ + pull(controller) { + if (sent) { + controller.close() + return + } + + controller.enqueue(bytes) + sent = true + }, + }) +} + export function tusConformanceInputOptions(conformanceScenario) { const options = {} for (const entry of conformanceScenario.inputOptionEntries) { @@ -250,13 +272,39 @@ export function tusConformanceInputOptions(conformanceScenario) { return options } -export function tusConformanceUploadInput(conformanceScenario) { +export async function tusConformanceUploadInput(conformanceScenario) { const inputSource = conformanceScenario.inputSource - if (inputSource.kind !== 'blob') { - fail(`TUS conformance scenario cannot build input kind ${JSON.stringify(inputSource.kind)}`) + if (inputSource.kind === 'blob') { + return new Blob([inputSource.content]) } - return new Blob([inputSource.content]) + if (inputSource.kind === 'array-buffer') { + const bytes = contentBytes(inputSource.content) + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + } + + if (inputSource.kind === 'array-buffer-view') { + return contentBytes(inputSource.content) + } + + if (inputSource.kind === 'web-readable-stream') { + return readableStreamFromContent(inputSource.content) + } + + if (inputSource.kind === 'node-readable-stream') { + return Readable.from([Buffer.from(contentBytes(inputSource.content))]) + } + + if (inputSource.kind === 'node-path-reference') { + const filePath = path.join( + tmpdir(), + `tus-js-client-api2-${conformanceScenario.scenarioId}-input.bin`, + ) + await writeFile(filePath, contentBytes(inputSource.content)) + return { path: filePath } + } + + fail(`TUS conformance scenario cannot build input kind ${JSON.stringify(inputSource.kind)}`) } class ContractResponse { @@ -347,6 +395,43 @@ class ContractRequest { } } +export function tusConformanceScenarioWantsEvent(conformanceScenario, kind) { + return conformanceScenario.events.some((event) => event.kind === kind) +} + +export function tusConformanceEventRecordingFileReader({ + conformanceScenario, + events, + fileReader, +}) { + return { + async openFile(input, chunkSize) { + const source = await fileReader.openFile(input, chunkSize) + + if (tusConformanceScenarioWantsEvent(conformanceScenario, 'source-open')) { + events.push({ + inputKind: conformanceScenario.inputSource.kind, + kind: 'source-open', + size: source.size, + }) + } + + return { + get size() { + return source.size + }, + close() { + events.push({ kind: 'source-close' }) + source.close() + }, + slice(start, end) { + return source.slice(start, end) + }, + } + }, + } +} + export class TusConformanceHttpStack { constructor(conformanceScenario) { this.conformanceScenario = conformanceScenario diff --git a/examples/api2-devdock-tus-node-path-input-source/main.js b/examples/api2-devdock-tus-node-path-input-source/main.js new file mode 100644 index 000000000..b6923cf59 --- /dev/null +++ b/examples/api2-devdock-tus-node-path-input-source/main.js @@ -0,0 +1,81 @@ +import { defaultOptions, Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceEventRecordingFileReader, + tusConformanceInputOptions, + tusConformanceScenarioWantsEvent, + tusConformanceUploadInput, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithNodePathInputSource(conformanceScenario) { + const inputOptions = tusConformanceInputOptions(conformanceScenario) + const content = await tusConformanceUploadInput(conformanceScenario) + const events = [] + const httpStack = new TusConformanceHttpStack(conformanceScenario) + const options = { + endpoint: inputOptions.endpointUrl, + fileReader: tusConformanceEventRecordingFileReader({ + conformanceScenario, + events, + fileReader: defaultOptions.fileReader, + }), + httpStack, + metadata: inputOptions.metadata, + } + + if (inputOptions.chunkSize !== undefined) { + options.chunkSize = inputOptions.chunkSize + } + + if (inputOptions.uploadLengthDeferred !== undefined) { + options.uploadLengthDeferred = inputOptions.uploadLengthDeferred + } + + const upload = new Upload(content, options) + + await new Promise((resolve, reject) => { + upload.options.onError = reject + upload.options.onSuccess = resolve + upload.start() + }) + + if (tusConformanceScenarioWantsEvent(conformanceScenario, 'success')) { + events.push({ kind: 'success' }) + } + + if (!upload.url) { + fail('node path input source scenario did not expose an upload URL') + } + + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `node path input source scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + events, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadWithNodePathInputSource(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} read ${conformanceScenario.inputSource.kind} for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/api2-devdock-tus-override-patch-method/main.js b/examples/api2-devdock-tus-override-patch-method/main.js index 7cdf56d54..80e45690e 100644 --- a/examples/api2-devdock-tus-override-patch-method/main.js +++ b/examples/api2-devdock-tus-override-patch-method/main.js @@ -11,7 +11,7 @@ import { async function uploadWithOverridePatchMethod(conformanceScenario) { const inputOptions = tusConformanceInputOptions(conformanceScenario) - const content = tusConformanceUploadInput(conformanceScenario) + const content = await tusConformanceUploadInput(conformanceScenario) const httpStack = new TusConformanceHttpStack(conformanceScenario) const upload = new Upload(content, { endpoint: inputOptions.endpointUrl, diff --git a/examples/api2-devdock-tus-relative-location-resolution/main.js b/examples/api2-devdock-tus-relative-location-resolution/main.js index db698ab63..8b5d24760 100644 --- a/examples/api2-devdock-tus-relative-location-resolution/main.js +++ b/examples/api2-devdock-tus-relative-location-resolution/main.js @@ -11,7 +11,7 @@ import { async function uploadWithRelativeLocationResolution(conformanceScenario) { const inputOptions = tusConformanceInputOptions(conformanceScenario) - const content = tusConformanceUploadInput(conformanceScenario) + const content = await tusConformanceUploadInput(conformanceScenario) const httpStack = new TusConformanceHttpStack(conformanceScenario) const upload = new Upload(content, { endpoint: inputOptions.endpointUrl, From fcdf4ddddfc8d5508066980b8d4187c64ad03045 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 04:41:45 +0200 Subject: [PATCH 142/155] Fix API2 node path input proof --- examples/api2-devdock-shared/scenario.js | 55 +++++++++++++++---- .../main.js | 11 ++-- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 405806da4..13f022aea 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -219,7 +219,7 @@ export function requireTusConformanceScenario(scenario) { return conformanceScenario } -function bodySize(body) { +function fixedBodySize(body) { if (body == null) { return null } @@ -243,6 +243,40 @@ function bodySize(body) { fail(`TUS conformance scenario cannot measure request body ${typeof body}`) } +function chunkSize(chunk) { + if (typeof chunk === 'string') { + return Buffer.byteLength(chunk) + } + + if (chunk instanceof ArrayBuffer) { + return chunk.byteLength + } + + if (ArrayBuffer.isView(chunk)) { + return chunk.byteLength + } + + fail(`TUS conformance scenario cannot measure request body chunk ${typeof chunk}`) +} + +async function streamBodySize(body, progressHandler) { + let size = 0 + for await (const chunk of body) { + size += chunkSize(chunk) + progressHandler(size) + } + + return size +} + +async function bodySize(body, progressHandler) { + if (body instanceof Readable) { + return await streamBodySize(body, progressHandler) + } + + return fixedBodySize(body) +} + function contentBytes(content) { return new TextEncoder().encode(content) } @@ -358,14 +392,7 @@ class ContractRequest { this.progressHandler = progressHandler } - send(body = null) { - const size = bodySize(body) - if (size !== this.requestPlan.bodySize) { - fail( - `TUS conformance scenario expected request body size ${this.requestPlan.bodySize}, got ${size}`, - ) - } - + async send(body = null) { for (const [header, value] of Object.entries(this.requestPlan.effectiveHeaders)) { if (this.headers[header] !== value) { fail( @@ -374,7 +401,15 @@ class ContractRequest { } } - if (size != null) { + const isStreamBody = body instanceof Readable + const size = await bodySize(body, this.progressHandler) + if (size !== this.requestPlan.bodySize) { + fail( + `TUS conformance scenario expected request body size ${this.requestPlan.bodySize}, got ${size}`, + ) + } + + if (size != null && !isStreamBody) { this.progressHandler(0) this.progressHandler(size) } diff --git a/examples/api2-devdock-tus-node-path-input-source/main.js b/examples/api2-devdock-tus-node-path-input-source/main.js index b6923cf59..4c0c47e85 100644 --- a/examples/api2-devdock-tus-node-path-input-source/main.js +++ b/examples/api2-devdock-tus-node-path-input-source/main.js @@ -39,14 +39,15 @@ async function uploadWithNodePathInputSource(conformanceScenario) { await new Promise((resolve, reject) => { upload.options.onError = reject - upload.options.onSuccess = resolve + upload.options.onSuccess = (payload) => { + if (tusConformanceScenarioWantsEvent(conformanceScenario, 'success')) { + events.push({ kind: 'success' }) + } + resolve(payload) + } upload.start() }) - if (tusConformanceScenarioWantsEvent(conformanceScenario, 'success')) { - events.push({ kind: 'success' }) - } - if (!upload.url) { fail('node path input source scenario did not expose an upload URL') } From c6c1ccb370f23aa9ff55efc8841ab256f8aec531 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 05:36:12 +0200 Subject: [PATCH 143/155] Add API2 start option validation proof --- examples/api2-devdock-shared/scenario.js | 44 ++++++++++++ .../main.js | 67 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 examples/api2-devdock-tus-start-option-validation/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 13f022aea..99e8ca22c 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -297,6 +297,25 @@ function readableStreamFromContent(content) { }) } +const sameNameTusConformanceInputOptionKeys = new Set([ + 'addRequestId', + 'chunkSize', + 'headers', + 'metadata', + 'metadataForPartialUploads', + 'overridePatchMethod', + 'parallelUploadBoundaries', + 'parallelUploads', + 'protocol', + 'removeFingerprintOnSuccess', + 'retryDelays', + 'storeFingerprintForResuming', + 'uploadDataDuringCreation', + 'uploadLengthDeferred', + 'uploadSize', + 'uploadUrl', +]) + export function tusConformanceInputOptions(conformanceScenario) { const options = {} for (const entry of conformanceScenario.inputOptionEntries) { @@ -306,6 +325,31 @@ export function tusConformanceInputOptions(conformanceScenario) { return options } +export function tusConformanceUploadOptions(conformanceScenario) { + const options = {} + + for (const entry of conformanceScenario.inputOptionEntries) { + if (entry.key === 'endpointUrl') { + options.endpoint = entry.value + continue + } + + if (entry.key === 'rawOptions') { + Object.assign(options, entry.value) + continue + } + + if (sameNameTusConformanceInputOptionKeys.has(entry.key)) { + options[entry.key] = entry.value + continue + } + + fail(`TUS conformance scenario cannot map input option ${JSON.stringify(entry.key)}`) + } + + return options +} + export async function tusConformanceUploadInput(conformanceScenario) { const inputSource = conformanceScenario.inputSource if (inputSource.kind === 'blob') { diff --git a/examples/api2-devdock-tus-start-option-validation/main.js b/examples/api2-devdock-tus-start-option-validation/main.js new file mode 100644 index 000000000..3ed64075f --- /dev/null +++ b/examples/api2-devdock-tus-start-option-validation/main.js @@ -0,0 +1,67 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function validateStartOptions(conformanceScenario) { + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario) + const upload = new Upload(content, { + ...tusConformanceUploadOptions(conformanceScenario), + httpStack, + }) + + let capturedError = null + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('start option validation scenario did not fail before timeout')) + }, 1000) + + upload.options.onError = (error) => { + clearTimeout(timeout) + capturedError = error + resolve() + } + upload.options.onSuccess = () => { + clearTimeout(timeout) + reject(new Error('start option validation scenario unexpectedly succeeded')) + } + + upload.start() + }) + + if (!(capturedError instanceof Error)) { + fail('start option validation scenario did not capture an Error instance') + } + + if (httpStack.nextRequestIndex !== 0) { + fail(`start option validation scenario expected no requests, got ${httpStack.nextRequestIndex}`) + } + + return { + errorCaught: true, + errorMessage: capturedError.message, + requestCount: httpStack.nextRequestIndex, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await validateStartOptions(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} rejected ${conformanceScenario.completion.reason}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From dde1b5c56c12b90959160cf6156f689ee6bcbf95 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 06:35:28 +0200 Subject: [PATCH 144/155] Add API2 detailed error proof --- examples/api2-devdock-shared/scenario.js | 8 ++ .../api2-devdock-tus-detailed-error/main.js | 127 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 examples/api2-devdock-tus-detailed-error/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 99e8ca22c..8cf2252d4 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -462,6 +462,14 @@ class ContractRequest { this.observed.requestMethods.push(this.requestPlan.effectiveMethod) this.observed.requestUrls.push(this.url) + if (this.requestPlan.errorMessage) { + return Promise.reject(new Error(this.requestPlan.errorMessage)) + } + + if (!this.requestPlan.response) { + fail('TUS conformance scenario request has no response or error plan') + } + return Promise.resolve(new ContractResponse(this.requestPlan.response)) } diff --git a/examples/api2-devdock-tus-detailed-error/main.js b/examples/api2-devdock-tus-detailed-error/main.js new file mode 100644 index 000000000..0d838705d --- /dev/null +++ b/examples/api2-devdock-tus-detailed-error/main.js @@ -0,0 +1,127 @@ +import { DetailedError, Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function detailedErrorRequestIdHeaderName(conformanceScenario) { + const inputHeadersEntry = conformanceScenario.inputOptionEntries.find((entry) => { + return entry.key === 'headers' + }) + const inputHeaders = inputHeadersEntry?.value + if (typeof inputHeaders !== 'object' || inputHeaders === null || Array.isArray(inputHeaders)) { + fail('detailed error scenario is missing request headers input options') + } + + const expectedHeaders = conformanceScenario.requests[0]?.effectiveHeaders + if ( + typeof expectedHeaders !== 'object' || + expectedHeaders === null || + Array.isArray(expectedHeaders) + ) { + fail('detailed error scenario is missing expected request headers') + } + + const matchingHeaderNames = Object.entries(inputHeaders) + .filter(([name, value]) => expectedHeaders[name] === value) + .map(([name]) => name) + + if (matchingHeaderNames.length !== 1) { + fail( + `detailed error scenario expected one request ID header candidate, got ${matchingHeaderNames.length}`, + ) + } + + return matchingHeaderNames[0] +} + +async function uploadExpectingDetailedError(conformanceScenario) { + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario) + const upload = new Upload(content, { + ...tusConformanceUploadOptions(conformanceScenario), + httpStack, + }) + + let capturedError = null + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('detailed error scenario did not fail before timeout')) + }, 1000) + + upload.options.onError = (error) => { + clearTimeout(timeout) + capturedError = error + resolve() + } + upload.options.onSuccess = () => { + clearTimeout(timeout) + reject(new Error('detailed error scenario unexpectedly succeeded')) + } + + upload.start() + }) + + if (!(capturedError instanceof Error)) { + fail('detailed error scenario did not capture an Error instance') + } + + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `detailed error scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + const originalRequest = capturedError.originalRequest + if (!originalRequest) { + fail('detailed error scenario did not expose the original request') + } + + const requestIdHeaderName = detailedErrorRequestIdHeaderName(conformanceScenario) + const originalResponse = capturedError.originalResponse ?? null + const causingError = capturedError.causingError ?? null + const result = { + causingErrorPresent: causingError instanceof Error, + errorCaught: true, + errorIsDetailed: capturedError instanceof DetailedError, + errorMessage: capturedError.message, + originalRequestMethod: originalRequest.getMethod(), + originalRequestRequestId: originalRequest.getHeader(requestIdHeaderName), + originalRequestUrl: originalRequest.getURL(), + originalResponsePresent: originalResponse !== null, + requestCount: httpStack.nextRequestIndex, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + } + + if (causingError instanceof Error) { + result.causingErrorMessage = causingError.message + } + + if (originalResponse !== null) { + result.originalResponseBody = originalResponse.getBody() + result.originalResponseStatus = originalResponse.getStatus() + } + + return result +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadExpectingDetailedError(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} reported ${conformanceScenario.completion.reason}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 0a05ee384c925f55b16b0a1872825b0e6ddf170f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 08:28:20 +0200 Subject: [PATCH 145/155] Prove API2 file URL storage backend --- .../api2-devdock-tus-resume-upload/main.js | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/examples/api2-devdock-tus-resume-upload/main.js b/examples/api2-devdock-tus-resume-upload/main.js index a1ac47bba..f164a67ba 100644 --- a/examples/api2-devdock-tus-resume-upload/main.js +++ b/examples/api2-devdock-tus-resume-upload/main.js @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -103,6 +103,7 @@ async function resumeStoredUpload({ content, createResponse, scenario, storage } return { previousUploadCount: previousUploads.length, remainingPreviousUploadCount: remainingPreviousUploads.length, + storedUploadKey: previousUpload.urlStorageKey, uploadUrl: upload.url, } } @@ -122,12 +123,27 @@ async function uploadWithStoredResume(scenario, createResponse) { storage, }) const resumedUpload = await resumeStoredUpload({ content, createResponse, scenario, storage }) - - return { + const urlStorageBackend = scenario.upload.urlStorageBackend + const result = { firstAcceptedBytes: firstUpload.acceptedBytes, firstUploadUrl: firstUpload.firstUploadUrl, ...resumedUpload, } + + if (urlStorageBackend) { + if (urlStorageBackend.kind !== 'file') { + fail(`resume scenario expected file URL storage backend, got ${urlStorageBackend.kind}`) + } + + const storageFile = JSON.parse(await readFile(storagePath, 'utf8')) + result.storedUploadKeyPrefixMatched = resumedUpload.storedUploadKey.startsWith( + urlStorageBackend.expectedStoredUploadKeyPrefix, + ) + result.storageFileEntryCount = Object.keys(storageFile).length + result.urlStorageBackend = urlStorageBackend.kind + } + + return result } finally { await rm(tempDir, { force: true, recursive: true }) } From 653954c697fd6803bffcb383347ff9e2ceee74f9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 09:32:30 +0200 Subject: [PATCH 146/155] Add API2 abort upload proof --- examples/api2-devdock-shared/scenario.js | 79 ++++++++++++- .../api2-devdock-tus-abort-upload/main.js | 106 ++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 examples/api2-devdock-tus-abort-upload/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 8cf2252d4..94d312f08 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { TUS_DEFAULT_CLIENT_PROTOCOL, + tusAbortErrorDescriptor, tusRequestHeadersForProtocol, } from '../../lib.esm/protocol_generated.js' @@ -350,6 +351,30 @@ export function tusConformanceUploadOptions(conformanceScenario) { return options } +export function tusConformanceRuntimeSetupOptions(conformanceScenario) { + const runtimeSetup = conformanceScenario.runtimeSetup + if (typeof runtimeSetup !== 'object' || runtimeSetup === null || Array.isArray(runtimeSetup)) { + return {} + } + + const options = {} + const fingerprint = runtimeSetup.fingerprint + if ( + typeof fingerprint === 'object' && + fingerprint !== null && + !Array.isArray(fingerprint) && + fingerprint.install === true + ) { + if (typeof fingerprint.value !== 'string') { + fail('TUS conformance scenario asked to install a fingerprint without a string value') + } + + options.fingerprint = async () => fingerprint.value + } + + return options +} + export async function tusConformanceUploadInput(conformanceScenario) { const inputSource = conformanceScenario.inputSource if (inputSource.kind === 'blob') { @@ -408,11 +433,16 @@ class ContractResponse { } class ContractRequest { - constructor({ observed, requestPlan, url }) { + constructor({ events, observed, onRequestStart, requestPlan, url }) { this.headers = {} + this.events = events this.observed = observed + this.onRequestStart = onRequestStart this.requestPlan = requestPlan this.url = url + this.abortReject = null + this.abortRecorded = false + this.aborted = false this.progressHandler = () => {} } @@ -462,6 +492,18 @@ class ContractRequest { this.observed.requestMethods.push(this.requestPlan.effectiveMethod) this.observed.requestUrls.push(this.url) + if (this.requestPlan.abort) { + return new Promise((_resolve, reject) => { + this.abortReject = reject + this.onRequestStart(this.requestPlan) + if (this.aborted) { + this.rejectAbort() + } + }) + } + + this.onRequestStart(this.requestPlan) + if (this.requestPlan.errorMessage) { return Promise.reject(new Error(this.requestPlan.errorMessage)) } @@ -474,9 +516,38 @@ class ContractRequest { } abort() { + if (this.requestPlan.abort !== true) { + fail( + `TUS conformance scenario did not expect request ${this.requestPlan.requestIndex} to be aborted`, + ) + } + + this.aborted = true + if (!this.abortRecorded) { + this.events.push({ + kind: 'request-abort', + method: this.requestPlan.effectiveMethod, + requestIndex: this.requestPlan.requestIndex, + url: this.url, + }) + this.abortRecorded = true + } + + this.rejectAbort() return Promise.resolve() } + rejectAbort() { + if (!this.abortReject) { + return + } + + const reject = this.abortReject + this.abortReject = null + const error = tusAbortErrorDescriptor() + reject(new DOMException(error.message, error.name)) + } + getUnderlyingObject() { return this.requestPlan } @@ -520,9 +591,11 @@ export function tusConformanceEventRecordingFileReader({ } export class TusConformanceHttpStack { - constructor(conformanceScenario) { + constructor(conformanceScenario, { events = [] } = {}) { this.conformanceScenario = conformanceScenario + this.events = events this.nextRequestIndex = 0 + this.onRequestStart = () => {} this.observed = { requestHeaders: [], requestMethods: [], @@ -549,7 +622,9 @@ export class TusConformanceHttpStack { } return new ContractRequest({ + events: this.events, observed: this.observed, + onRequestStart: this.onRequestStart, requestPlan, url, }) diff --git a/examples/api2-devdock-tus-abort-upload/main.js b/examples/api2-devdock-tus-abort-upload/main.js new file mode 100644 index 000000000..4c8ec4308 --- /dev/null +++ b/examples/api2-devdock-tus-abort-upload/main.js @@ -0,0 +1,106 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceRuntimeSetupOptions, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function cancelUploadActions(conformanceScenario) { + return conformanceScenario.execution.onRequestStart.filter((action) => { + return action.kind === 'cancel-upload' + }) +} + +async function waitForAbortPromises({ abortPromises, expectedCount }) { + const timeoutMs = 1000 + const startedAt = Date.now() + + while (abortPromises.length < expectedCount) { + if (Date.now() - startedAt > timeoutMs) { + fail(`abort scenario expected ${expectedCount} abort promise(s), got ${abortPromises.length}`) + } + + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + } +} + +async function uploadAndAbort(conformanceScenario) { + const content = await tusConformanceUploadInput(conformanceScenario) + const events = [] + const httpStack = new TusConformanceHttpStack(conformanceScenario, { events }) + const abortPromises = [] + const actions = cancelUploadActions(conformanceScenario) + let errorCalled = false + let successCalled = false + let upload = null + + httpStack.onRequestStart = (requestPlan) => { + for (const action of actions) { + if (action.requestIndex !== requestPlan.requestIndex) { + continue + } + + if (!upload) { + fail('abort scenario tried to cancel before the Upload was available') + } + + abortPromises.push(upload.abort(conformanceScenario.runtimeSetup.abort.terminateUpload)) + } + } + + upload = new Upload(content, { + ...tusConformanceUploadOptions(conformanceScenario), + ...tusConformanceRuntimeSetupOptions(conformanceScenario), + httpStack, + onError() { + errorCalled = true + }, + onSuccess() { + successCalled = true + }, + }) + + upload.start() + await waitForAbortPromises({ abortPromises, expectedCount: actions.length }) + await Promise.all(abortPromises) + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `abort scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + completionKind: 'aborted', + errorCalled, + events, + requestCount: httpStack.nextRequestIndex, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + successCalled, + uploadUrl: upload.url ?? null, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadAndAbort(conformanceScenario) + await writeJsonResult(result) + console.log(`TypeScript TUS SDK devdock scenario ${scenario.scenarioId} aborted the upload`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From a6baa2e2821cb1718833f824725caf9acbd184c9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 10:03:15 +0200 Subject: [PATCH 147/155] Add API2 retry state proof --- examples/api2-devdock-shared/scenario.js | 73 +++++++++++++++ .../main.js | 88 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 examples/api2-devdock-tus-retry-state-transitions/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 94d312f08..5f795ea3e 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -557,6 +557,79 @@ export function tusConformanceScenarioWantsEvent(conformanceScenario, kind) { return conformanceScenario.events.some((event) => event.kind === kind) } +export function tusConformanceRetryObserver(conformanceScenario, events) { + const retryDecisions = Array.isArray(conformanceScenario.retryDecisions) + ? conformanceScenario.retryDecisions + : [] + if (tusConformanceScenarioWantsEvent(conformanceScenario, 'should-retry')) { + if (retryDecisions.length === 0) { + fail('TUS conformance scenario wants retry decisions but exposes none') + } + } + + let allowedScheduleCount = 0 + let retryDecisionIndex = 0 + let restoreRetryTimer = () => {} + + if (tusConformanceScenarioWantsEvent(conformanceScenario, 'retry-schedule')) { + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = (handler, delay, ...args) => { + if (allowedScheduleCount > 0) { + events.push({ delay, kind: 'retry-schedule' }) + allowedScheduleCount -= 1 + } + + return originalSetTimeout(handler, delay, ...args) + } + restoreRetryTimer = () => { + globalThis.setTimeout = originalSetTimeout + } + } + + const onShouldRetry = + retryDecisions.length === 0 + ? undefined + : (_error, retryAttempt) => { + const retryDecision = retryDecisions[retryDecisionIndex] + if (!retryDecision) { + fail( + `TUS conformance scenario received unexpected retry decision request ${retryDecisionIndex}`, + ) + } + if (retryDecision.retryAttempt !== retryAttempt) { + fail( + `TUS conformance scenario expected retry attempt ${retryDecision.retryAttempt}, got ${retryAttempt}`, + ) + } + + events.push({ + decision: retryDecision.decision, + kind: 'should-retry', + retryAttempt, + }) + if (retryDecision.decision) { + allowedScheduleCount += 1 + } + retryDecisionIndex += 1 + return retryDecision.decision + } + + return { + assertComplete() { + if (retryDecisionIndex !== retryDecisions.length) { + fail( + `TUS conformance scenario expected ${retryDecisions.length} retry decision(s), got ${retryDecisionIndex}`, + ) + } + if (allowedScheduleCount !== 0) { + fail(`TUS conformance scenario left ${allowedScheduleCount} retry schedule(s) unobserved`) + } + }, + onShouldRetry, + restore: restoreRetryTimer, + } +} + export function tusConformanceEventRecordingFileReader({ conformanceScenario, events, diff --git a/examples/api2-devdock-tus-retry-state-transitions/main.js b/examples/api2-devdock-tus-retry-state-transitions/main.js new file mode 100644 index 000000000..8afa82d45 --- /dev/null +++ b/examples/api2-devdock-tus-retry-state-transitions/main.js @@ -0,0 +1,88 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceRetryObserver, + tusConformanceRuntimeSetupOptions, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithRetryStateTransitions(conformanceScenario) { + const events = [] + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario) + const retryObserver = tusConformanceRetryObserver(conformanceScenario, events) + const options = { + ...tusConformanceUploadOptions(conformanceScenario), + ...tusConformanceRuntimeSetupOptions(conformanceScenario), + httpStack, + } + if (retryObserver.onShouldRetry) { + options.onShouldRetry = retryObserver.onShouldRetry + } + + const upload = new Upload(content, options) + let completionKind = 'unknown' + let errorCalled = false + let successCalled = false + + try { + await new Promise((resolve, reject) => { + upload.options.onError = (error) => { + completionKind = 'error' + errorCalled = true + reject(error) + } + upload.options.onSuccess = () => { + completionKind = 'success' + successCalled = true + resolve() + } + upload.start() + }) + } finally { + retryObserver.restore() + } + + retryObserver.assertComplete() + + if (!upload.url) { + fail('retry state transition scenario did not expose an upload URL') + } + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `retry state transition scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + completionKind, + errorCalled, + eventCount: events.length, + events, + requestCount: httpStack.nextRequestIndex, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + successCalled, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadWithRetryStateTransitions(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} observed ${result.eventCount} retry event(s) for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 7f5ca57f5af5288f3c64ec4cad56b3ea0ee07de6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 10:20:14 +0200 Subject: [PATCH 148/155] Add API2 protocol version proof --- examples/api2-devdock-shared/scenario.js | 18 ++++ .../main.js | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 examples/api2-devdock-tus-protocol-version-selection/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 5f795ea3e..e5ba77efd 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -475,6 +475,24 @@ class ContractRequest { } } + for (const header of this.requestPlan.absentHeaders) { + if (Object.hasOwn(this.headers, header)) { + fail( + `TUS conformance scenario expected request header ${header} to be absent, got ${JSON.stringify(this.headers[header])}`, + ) + } + } + + if (this.requestPlan.headerMode === 'exact') { + for (const header of Object.keys(this.headers)) { + if (!Object.hasOwn(this.requestPlan.effectiveHeaders, header)) { + fail( + `TUS conformance scenario did not expect request header ${header}=${JSON.stringify(this.headers[header])}`, + ) + } + } + } + const isStreamBody = body instanceof Readable const size = await bodySize(body, this.progressHandler) if (size !== this.requestPlan.bodySize) { diff --git a/examples/api2-devdock-tus-protocol-version-selection/main.js b/examples/api2-devdock-tus-protocol-version-selection/main.js new file mode 100644 index 000000000..ec33e39de --- /dev/null +++ b/examples/api2-devdock-tus-protocol-version-selection/main.js @@ -0,0 +1,88 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +function absentHeaderPresence(conformanceScenario, requestHeaders) { + return conformanceScenario.requests.map((request, requestIndex) => { + const observedHeaders = requestHeaders[requestIndex] + if (!observedHeaders) { + fail(`protocol version scenario did not capture request ${requestIndex} headers`) + } + + return Object.fromEntries( + request.absentHeaders.map((header) => [header, Object.hasOwn(observedHeaders, header)]), + ) + }) +} + +async function uploadWithProtocolVersionSelection(conformanceScenario) { + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario) + const upload = new Upload(content, { + ...tusConformanceUploadOptions(conformanceScenario), + httpStack, + }) + let completionKind = 'unknown' + let errorCalled = false + let successCalled = false + + await new Promise((resolve, reject) => { + upload.options.onError = (error) => { + completionKind = 'error' + errorCalled = true + reject(error) + } + upload.options.onSuccess = () => { + completionKind = 'success' + successCalled = true + resolve() + } + upload.start() + }) + + if (!upload.url) { + fail('protocol version scenario did not expose an upload URL') + } + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `protocol version scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + absentHeaderPresence: absentHeaderPresence( + conformanceScenario, + httpStack.observed.requestHeaders, + ), + completionKind, + errorCalled, + requestCount: httpStack.nextRequestIndex, + requestHeaders: httpStack.observed.requestHeaders, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + successCalled, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadWithProtocolVersionSelection(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} selected ${conformanceScenario.inputOptionEntries.find((entry) => entry.key === 'protocol')?.value ?? 'the default protocol'} for ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 0ef943e202f2e9750a7ba522ef33ba5b881fefb2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 10:36:03 +0200 Subject: [PATCH 149/155] Add API2 parallel upload proof --- examples/api2-devdock-shared/scenario.js | 121 ++++++++++++++---- .../main.js | 96 ++++++++++++++ .../main.js | 14 +- 3 files changed, 196 insertions(+), 35 deletions(-) create mode 100644 examples/api2-devdock-tus-parallel-upload-concat/main.js diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index e5ba77efd..482c4f232 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -220,62 +220,102 @@ export function requireTusConformanceScenario(scenario) { return conformanceScenario } -function fixedBodySize(body) { +function concatBytes(parts) { + const size = parts.reduce((sum, part) => sum + part.byteLength, 0) + const result = new Uint8Array(size) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.byteLength + } + + return result +} + +function bytesEqual(left, right) { + if (left.byteLength !== right.byteLength) { + return false + } + + return left.every((value, index) => value === right[index]) +} + +function byteOffset(haystack, needle) { + if (needle.byteLength === 0) { + return 0 + } + + for (let offset = 0; offset <= haystack.byteLength - needle.byteLength; offset += 1) { + if (bytesEqual(haystack.slice(offset, offset + needle.byteLength), needle)) { + return offset + } + } + + return null +} + +async function fixedBodySnapshot(body) { if (body == null) { - return null + return { bytes: null, size: null } } if (body instanceof Blob) { - return body.size + const bytes = new Uint8Array(await body.arrayBuffer()) + return { bytes, size: bytes.byteLength } } if (body instanceof ArrayBuffer) { - return body.byteLength + const bytes = new Uint8Array(body) + return { bytes, size: bytes.byteLength } } if (ArrayBuffer.isView(body)) { - return body.byteLength + const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength) + return { bytes, size: bytes.byteLength } } if (typeof body.length === 'number') { - return body.length + const bytes = Buffer.isBuffer(body) ? body : null + return { bytes, size: body.length } } fail(`TUS conformance scenario cannot measure request body ${typeof body}`) } -function chunkSize(chunk) { +function chunkBytes(chunk) { if (typeof chunk === 'string') { - return Buffer.byteLength(chunk) + return Buffer.from(chunk) } if (chunk instanceof ArrayBuffer) { - return chunk.byteLength + return new Uint8Array(chunk) } if (ArrayBuffer.isView(chunk)) { - return chunk.byteLength + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) } fail(`TUS conformance scenario cannot measure request body chunk ${typeof chunk}`) } -async function streamBodySize(body, progressHandler) { - let size = 0 +async function streamBodySnapshot(body, progressHandler) { + const parts = [] for await (const chunk of body) { - size += chunkSize(chunk) - progressHandler(size) + const bytes = chunkBytes(chunk) + parts.push(bytes) + progressHandler(parts.reduce((sum, part) => sum + part.byteLength, 0)) } - return size + const bytes = concatBytes(parts) + return { bytes, size: bytes.byteLength } } -async function bodySize(body, progressHandler) { +async function bodySnapshot(body, progressHandler) { if (body instanceof Readable) { - return await streamBodySize(body, progressHandler) + return { ...(await streamBodySnapshot(body, progressHandler)), isStream: true } } - return fixedBodySize(body) + return { ...(await fixedBodySnapshot(body)), isStream: false } } function contentBytes(content) { @@ -375,6 +415,19 @@ export function tusConformanceRuntimeSetupOptions(conformanceScenario) { return options } +export function absentHeaderPresence(conformanceScenario, requestHeaders) { + return conformanceScenario.requests.map((request, requestIndex) => { + const observedHeaders = requestHeaders[requestIndex] + if (!observedHeaders) { + fail(`TUS conformance scenario did not capture request ${requestIndex} headers`) + } + + return Object.fromEntries( + request.absentHeaders.map((header) => [header, Object.hasOwn(observedHeaders, header)]), + ) + }) +} + export async function tusConformanceUploadInput(conformanceScenario) { const inputSource = conformanceScenario.inputSource if (inputSource.kind === 'blob') { @@ -433,9 +486,10 @@ class ContractResponse { } class ContractRequest { - constructor({ events, observed, onRequestStart, requestPlan, url }) { + constructor({ events, inputContent, observed, onRequestStart, requestPlan, url }) { this.headers = {} this.events = events + this.inputContent = inputContent this.observed = observed this.onRequestStart = onRequestStart this.requestPlan = requestPlan @@ -493,19 +547,35 @@ class ContractRequest { } } - const isStreamBody = body instanceof Readable - const size = await bodySize(body, this.progressHandler) + const bodyInfo = await bodySnapshot(body, this.progressHandler) + const size = bodyInfo.size if (size !== this.requestPlan.bodySize) { fail( `TUS conformance scenario expected request body size ${this.requestPlan.bodySize}, got ${size}`, ) } - if (size != null && !isStreamBody) { + let bodyStart = null + if (bodyInfo.bytes != null && this.inputContent != null) { + bodyStart = byteOffset(this.inputContent, bodyInfo.bytes) + } + + if ( + typeof this.requestPlan.bodyStart === 'number' && + bodyStart !== this.requestPlan.bodyStart + ) { + fail( + `TUS conformance scenario expected request body start ${this.requestPlan.bodyStart}, got ${bodyStart}`, + ) + } + + if (size != null && !bodyInfo.isStream) { this.progressHandler(0) this.progressHandler(size) } + this.observed.requestBodySizes.push(size) + this.observed.requestBodyStarts.push(bodyStart) this.observed.requestHeaders.push({ ...this.headers }) this.observed.requestMethods.push(this.requestPlan.effectiveMethod) this.observed.requestUrls.push(this.url) @@ -685,9 +755,15 @@ export class TusConformanceHttpStack { constructor(conformanceScenario, { events = [] } = {}) { this.conformanceScenario = conformanceScenario this.events = events + this.inputContent = + typeof conformanceScenario.inputSource?.content === 'string' + ? contentBytes(conformanceScenario.inputSource.content) + : null this.nextRequestIndex = 0 this.onRequestStart = () => {} this.observed = { + requestBodySizes: [], + requestBodyStarts: [], requestHeaders: [], requestMethods: [], requestUrls: [], @@ -714,6 +790,7 @@ export class TusConformanceHttpStack { return new ContractRequest({ events: this.events, + inputContent: this.inputContent, observed: this.observed, onRequestStart: this.onRequestStart, requestPlan, diff --git a/examples/api2-devdock-tus-parallel-upload-concat/main.js b/examples/api2-devdock-tus-parallel-upload-concat/main.js new file mode 100644 index 000000000..ddf62872c --- /dev/null +++ b/examples/api2-devdock-tus-parallel-upload-concat/main.js @@ -0,0 +1,96 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + absentHeaderPresence, + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithParallelConcat(conformanceScenario) { + const events = [] + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario, { events }) + const upload = new Upload(content, { + ...tusConformanceUploadOptions(conformanceScenario), + httpStack, + onChunkComplete: (chunkSize, bytesAccepted, bytesTotal) => { + events.push({ + bytesAccepted, + bytesTotal, + chunkSize, + kind: 'chunk-complete', + }) + }, + onProgress: (bytesSent, bytesTotal) => { + events.push({ + bytesSent, + bytesTotal, + kind: 'progress', + }) + }, + }) + let completionKind = 'unknown' + let errorCalled = false + let successCalled = false + + await new Promise((resolve, reject) => { + upload.options.onError = (error) => { + completionKind = 'error' + errorCalled = true + reject(error) + } + upload.options.onSuccess = () => { + completionKind = 'success' + successCalled = true + resolve() + } + upload.start() + }) + + if (!upload.url) { + fail('parallel upload concat scenario did not expose an upload URL') + } + if (httpStack.nextRequestIndex !== conformanceScenario.requests.length) { + fail( + `parallel upload concat scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, + ) + } + + return { + absentHeaderPresence: absentHeaderPresence( + conformanceScenario, + httpStack.observed.requestHeaders, + ), + completionKind, + errorCalled, + eventCount: events.length, + events, + requestBodySizes: httpStack.observed.requestBodySizes, + requestBodyStarts: httpStack.observed.requestBodyStarts, + requestCount: httpStack.nextRequestIndex, + requestHeaders: httpStack.observed.requestHeaders, + requestMethods: httpStack.observed.requestMethods, + requestUrls: httpStack.observed.requestUrls, + successCalled, + uploadUrl: upload.url, + } +} + +async function main() { + const scenario = await loadScenario(import.meta.url) + const conformanceScenario = requireTusConformanceScenario(scenario) + const result = await uploadWithParallelConcat(conformanceScenario) + await writeJsonResult(result) + console.log( + `TypeScript TUS SDK devdock scenario ${scenario.scenarioId} concatenated ${conformanceScenario.inputOptionEntries.find((entry) => entry.key === 'parallelUploads')?.value ?? 'parallel'} upload(s) into ${result.uploadUrl}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/api2-devdock-tus-protocol-version-selection/main.js b/examples/api2-devdock-tus-protocol-version-selection/main.js index ec33e39de..325131cd3 100644 --- a/examples/api2-devdock-tus-protocol-version-selection/main.js +++ b/examples/api2-devdock-tus-protocol-version-selection/main.js @@ -1,5 +1,6 @@ import { Upload } from '../../lib.esm/node/index.js' import { + absentHeaderPresence, fail, loadScenario, requireTusConformanceScenario, @@ -9,19 +10,6 @@ import { writeJsonResult, } from '../api2-devdock-shared/scenario.js' -function absentHeaderPresence(conformanceScenario, requestHeaders) { - return conformanceScenario.requests.map((request, requestIndex) => { - const observedHeaders = requestHeaders[requestIndex] - if (!observedHeaders) { - fail(`protocol version scenario did not capture request ${requestIndex} headers`) - } - - return Object.fromEntries( - request.absentHeaders.map((header) => [header, Object.hasOwn(observedHeaders, header)]), - ) - }) -} - async function uploadWithProtocolVersionSelection(conformanceScenario) { const content = await tusConformanceUploadInput(conformanceScenario) const httpStack = new TusConformanceHttpStack(conformanceScenario) From 5a61c70b71f0e6eaf620ed7555f05b2c09dd5052 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 10:41:47 +0200 Subject: [PATCH 150/155] Normalize API2 parallel event proof --- examples/api2-devdock-shared/scenario.js | 47 +++++++++++++++++++ .../main.js | 11 +++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js index 482c4f232..d7303b560 100644 --- a/examples/api2-devdock-shared/scenario.js +++ b/examples/api2-devdock-shared/scenario.js @@ -645,6 +645,53 @@ export function tusConformanceScenarioWantsEvent(conformanceScenario, kind) { return conformanceScenario.events.some((event) => event.kind === kind) } +function tusConformanceEventMatches(actualEvent, expectedEvent) { + return Object.entries(expectedEvent).every(([key, expectedValue]) => { + return key === 'key' || actualEvent[key] === expectedValue + }) +} + +function tusConformanceProjectedEvent(actualEvent, expectedEvent) { + const projectedEvent = {} + for (const key of Object.keys(expectedEvent)) { + if (key === 'key') { + continue + } + + projectedEvent[key] = actualEvent[key] + } + + return projectedEvent +} + +export function tusConformanceExpectedEventSequence(conformanceScenario, events) { + const projectedEvents = [] + let cursor = 0 + + for (const expectedEvent of conformanceScenario.events) { + let actualEvent = null + for (; cursor < events.length; cursor += 1) { + if (!tusConformanceEventMatches(events[cursor], expectedEvent)) { + continue + } + + actualEvent = events[cursor] + cursor += 1 + break + } + + if (actualEvent == null) { + fail( + `TUS conformance scenario did not observe expected event ${JSON.stringify(expectedEvent)}`, + ) + } + + projectedEvents.push(tusConformanceProjectedEvent(actualEvent, expectedEvent)) + } + + return projectedEvents +} + export function tusConformanceRetryObserver(conformanceScenario, events) { const retryDecisions = Array.isArray(conformanceScenario.retryDecisions) ? conformanceScenario.retryDecisions diff --git a/examples/api2-devdock-tus-parallel-upload-concat/main.js b/examples/api2-devdock-tus-parallel-upload-concat/main.js index ddf62872c..57a6434b8 100644 --- a/examples/api2-devdock-tus-parallel-upload-concat/main.js +++ b/examples/api2-devdock-tus-parallel-upload-concat/main.js @@ -5,20 +5,21 @@ import { loadScenario, requireTusConformanceScenario, TusConformanceHttpStack, + tusConformanceExpectedEventSequence, tusConformanceUploadInput, tusConformanceUploadOptions, writeJsonResult, } from '../api2-devdock-shared/scenario.js' async function uploadWithParallelConcat(conformanceScenario) { - const events = [] + const rawEvents = [] const content = await tusConformanceUploadInput(conformanceScenario) - const httpStack = new TusConformanceHttpStack(conformanceScenario, { events }) + const httpStack = new TusConformanceHttpStack(conformanceScenario, { events: rawEvents }) const upload = new Upload(content, { ...tusConformanceUploadOptions(conformanceScenario), httpStack, onChunkComplete: (chunkSize, bytesAccepted, bytesTotal) => { - events.push({ + rawEvents.push({ bytesAccepted, bytesTotal, chunkSize, @@ -26,7 +27,7 @@ async function uploadWithParallelConcat(conformanceScenario) { }) }, onProgress: (bytesSent, bytesTotal) => { - events.push({ + rawEvents.push({ bytesSent, bytesTotal, kind: 'progress', @@ -59,6 +60,7 @@ async function uploadWithParallelConcat(conformanceScenario) { `parallel upload concat scenario expected ${conformanceScenario.requests.length} request(s), got ${httpStack.nextRequestIndex}`, ) } + const events = tusConformanceExpectedEventSequence(conformanceScenario, rawEvents) return { absentHeaderPresence: absentHeaderPresence( @@ -69,6 +71,7 @@ async function uploadWithParallelConcat(conformanceScenario) { errorCalled, eventCount: events.length, events, + rawEventCount: rawEvents.length, requestBodySizes: httpStack.observed.requestBodySizes, requestBodyStarts: httpStack.observed.requestBodyStarts, requestCount: httpStack.nextRequestIndex, From 2cdc175ac58695135b932ec8c0dd9272cf404f9e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 02:45:04 +0200 Subject: [PATCH 151/155] Update generated detailed error policy --- lib/protocol_generated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/protocol_generated.ts b/lib/protocol_generated.ts index b249fc65e..0a9fecfbf 100644 --- a/lib/protocol_generated.ts +++ b/lib/protocol_generated.ts @@ -212,6 +212,7 @@ export const TUS_FLOW_POLICY = { terminateUploadContext: 'detached-from-aborted-request', }, detailedErrors: { + causeStringTemplate: 'Error: {message}', causedByTemplate: ', caused by {cause}', emptyResponseBody: '', missingValue: 'n/a', From 8ea149a6c7bd68950476fbd27c01d73a665099eb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 21:14:28 +0200 Subject: [PATCH 152/155] Generate the termination retry loop from statement IR lib/terminate_generated.ts is walked from the same shared terminate-upload-with-retry procedure the Go, C#, and Python clients emit. The retry decisions stay in protocol_generated.ts (tusPlanRetryAfterError and friends); the new module owns only the loop, with the DELETE transport and the wait timer passed in as host closures. terminate() delegates with its public signature and DetailedError behavior unchanged; the handwritten recursive retry is deleted. Co-Authored-By: Claude Fable 5 --- lib/terminate_generated.ts | 95 ++++++++++++++++++++++++++++++++++++++ lib/upload.ts | 76 +++++++++++------------------- 2 files changed, 122 insertions(+), 49 deletions(-) create mode 100644 lib/terminate_generated.ts diff --git a/lib/terminate_generated.ts b/lib/terminate_generated.ts new file mode 100644 index 000000000..5968b54bd --- /dev/null +++ b/lib/terminate_generated.ts @@ -0,0 +1,95 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + +import { DetailedError } from './DetailedError.js' +import { + tusNextRetryAttempt, + tusPlanRetryAfterError, + tusShouldEvaluateRetryPolicy, + tusShouldTreatRequestErrorAsRetryable, +} from './protocol_generated.js' + +export interface TusTerminateUploadWithRetryInput { + evaluateRetryPolicy: (error: DetailedError, retryAttempt: number) => boolean + retryDelays: readonly number[] | null + sendTerminateRequest: (uploadUrl: string) => Promise + sleep: (delayMs: number) => Promise + uploadUrl: string +} + +export function tusShouldScheduleTerminateRetry({ + error, + evaluateRetryPolicy, + retryAttempt, + retryDelays, +}: { + error: DetailedError + evaluateRetryPolicy: (error: DetailedError, retryAttempt: number) => boolean + retryAttempt: number + retryDelays: readonly number[] +}): boolean { + const hasRetryableError = tusShouldTreatRequestErrorAsRetryable({ + hasRequestContext: error.originalRequest != null, + }) + let retryPlan = tusPlanRetryAfterError({ + isNetworkError: hasRetryableError, + offset: 0, + offsetBeforeRetry: 0, + retryAttempt, + retryDelays, + }) + + if (tusShouldEvaluateRetryPolicy({ hasRetryableError, retryPlanAction: retryPlan.action })) { + retryPlan = tusPlanRetryAfterError({ + isNetworkError: true, + offset: 0, + offsetBeforeRetry: 0, + retryAttempt: retryPlan.retryAttempt, + retryDelays, + shouldRetry: evaluateRetryPolicy(error, retryPlan.retryAttempt), + }) + } + + return retryPlan.action === 'scheduleRetry' +} + +export async function tusTerminateUploadWithRetry({ + evaluateRetryPolicy, + retryDelays, + sendTerminateRequest, + sleep, + uploadUrl, +}: TusTerminateUploadWithRetryInput): Promise { + const activeRetryDelays = retryDelays ?? [] + let retryAttempt = 0 + + while (true) { + let terminateError: DetailedError | null = null + try { + await sendTerminateRequest(uploadUrl) + } catch (error) { + if (!(error instanceof DetailedError)) { + throw error + } + + terminateError = error + } + if (terminateError == null) { + return + } + + const scheduleRetry = tusShouldScheduleTerminateRetry({ + error: terminateError, + evaluateRetryPolicy, + retryAttempt, + retryDelays: activeRetryDelays, + }) + if (!scheduleRetry) { + throw terminateError + } + + await sleep(activeRetryDelays[retryAttempt]) + retryAttempt = tusNextRetryAttempt({ retryAttempt }) + } +} diff --git a/lib/upload.ts b/lib/upload.ts index 187ce979c..0f54b441c 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -72,6 +72,7 @@ import { tusUrlStorageCreationTime, tusValidateUploadStart, } from './protocol_generated.js' +import { tusTerminateUploadWithRetry } from './terminate_generated.js' import { uuid } from './uuid.js' export const defaultOptions = { @@ -1164,16 +1165,11 @@ function wait(delay: number) { } /** - * Use the Termination extension to delete an upload from the server by sending a DELETE - * request to the specified upload URL. This is only possible if the server supports the - * Termination extension. If the `options.retryDelays` property is set, the method will - * also retry if an error ocurrs. + * Send a single DELETE request for the upload and surface any failure as a DetailedError. * - * @param {String} url The upload's URL which will be terminated. - * @param {object} options Optional options for influencing HTTP requests. - * @return {Promise} The Promise will be resolved/rejected when the requests finish. + * @api private */ -export async function terminate(url: string, options: UploadOptions): Promise { +async function sendTerminateRequest(url: string, options: UploadOptions): Promise { const terminateRequestPlan = tusPlanTerminateUploadRequest({ uploadUrl: url }) const plan = tusTerminateUploadRequestPlan({ protocol: options.protocol, @@ -1194,48 +1190,30 @@ export async function terminate(url: string, options: UploadOptions): Promise { + await tusTerminateUploadWithRetry({ + evaluateRetryPolicy: (error, retryAttempt) => shouldRetryByPolicy(error, retryAttempt, options), + retryDelays: options.retryDelays ?? null, + sendTerminateRequest: (uploadUrl) => sendTerminateRequest(uploadUrl, options), + sleep: wait, + uploadUrl: url, + }) +} From 6b8fda966180ccc52a081119c42600b490c2452d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 22:33:06 +0200 Subject: [PATCH 153/155] Generate the chunk upload loop from statement IR The _performUpload/_handleUploadResponse mutual recursion now emits from the shared statement-IR walker as lib/upload_chunks_generated.ts: the loop settles each response exchange (offset resync, accepted-bytes emissions, completion exit), checks abort at the test-pinned points, and PATCHes the next chunk through the host byte-source transport. Retry scheduling stays the handwritten _retryOrEmitError start() re-entry; errors leave the generated loop untouched on their way there. Co-Authored-By: Claude Fable 5 --- lib/upload.ts | 130 ++++++++++++++++++++------------- lib/upload_chunks_generated.ts | 65 +++++++++++++++++ 2 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 lib/upload_chunks_generated.ts diff --git a/lib/upload.ts b/lib/upload.ts index 0f54b441c..18f0581a8 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -73,8 +73,16 @@ import { tusValidateUploadStart, } from './protocol_generated.js' import { tusTerminateUploadWithRetry } from './terminate_generated.js' +import { tusUploadChunksUntilComplete } from './upload_chunks_generated.js' import { uuid } from './uuid.js' +// The request/response pair a chunk upload attempt settles through the generated chunk loop: +// the response carries the accepted offset, the request provides the error context. +interface TusChunkExchange { + req: HttpRequest + res: HttpResponse +} + export const defaultOptions = { endpoint: undefined, @@ -805,15 +813,53 @@ export class BaseUpload { * @api private */ private async _performUpload(): Promise { - // If the upload has been aborted, we will not send the next PATCH request. - // This is important if the abort method was called during a callback, such - // as onChunkComplete or onProgress. - if (this._aborted) { - return - } + await this._uploadChunks(null) + } - let req: HttpRequest + /** + * Run the generated chunk loop: settle a pending response exchange if one is supplied (the + * creation-with-data entry), then keep sending PATCH requests until the server holds the full + * upload. Abort checks, the emission order, and the completion exit live in the generated + * module; errors propagate to the caller's retry handling. + * + * @api private + */ + private async _uploadChunks(pendingExchange: TusChunkExchange | null): Promise { + await tusUploadChunksUntilComplete({ + applyChunkResponse: (exchange) => this._applyChunkResponse(exchange), + emitChunkComplete: (chunkSize, offset) => { + this._emitChunkComplete({ + bytesAccepted: offset, + bytesTotal: this._size, + chunkSize, + hasHook: typeof this.options.onChunkComplete === 'function', + phase: 'afterChunkAccepted', + }) + }, + emitProgressAfterChunkAccepted: (offset) => { + this._emitProgress({ + bytesTotal: this._size, + hasHook: typeof this.options.onProgress === 'function', + phase: 'afterChunkAccepted', + uploadOffset: offset, + }) + }, + emitSuccess: (exchange) => this._emitSuccess(exchange.res), + getOffset: () => this._offset, + getSize: () => this._size, + isAborted: () => this._aborted, + pendingExchange, + performPatchRequest: () => this._performPatchRequest(), + }) + } + /** + * Send a single PATCH request for the chunk at the current offset and surface any failure as a + * DetailedError. This is the per-attempt byte-source transport the generated chunk loop calls. + * + * @api private + */ + private async _performPatchRequest(): Promise { const chunkRequestPlan = tusPlanUploadChunkRequest({ offset: this._offset, uploadUrl: this.url, @@ -821,7 +867,7 @@ export class BaseUpload { if (!chunkRequestPlan.ok) { throw new Error(chunkRequestPlan.message) } - req = this._openRequest( + const req = this._openRequest( tusPatchUploadRequestPlan({ offset: this._offset, overridePatchMethod: this.options.overridePatchMethod, @@ -830,23 +876,39 @@ export class BaseUpload { }), ) - let res: HttpResponse try { - res = await this._addChunkToRequest(req) + const res = await this._addChunkToRequest(req) + return { req, res } } catch (err) { - // Don't emit an error if the upload was aborted manually - if (this._aborted) { - return - } - if (!(err instanceof Error)) { throw new Error(tusNonErrorThrownValueMessage({ value: err })) } throw new DetailedError(chunkRequestPlan.requestErrorMessage, err, req, undefined) } + } - await this._handleUploadResponse(req, res) + /** + * Read the accepted offset off a chunk response, fail on unusable responses, and advance the + * upload state. Returns the number of bytes the server newly accepted. + * + * @api private + */ + private _applyChunkResponse({ req, res }: TusChunkExchange): number { + const chunkResponsePlan = tusPlanUploadChunkResponse({ + currentOffset: this._offset, + response: tusReadUploadChunkResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }), + size: this._size, + }) + if (chunkResponsePlan.action === 'fail') { + throw new DetailedError(chunkResponsePlan.message, undefined, req, res) + } + + this._offset = chunkResponsePlan.offset + return chunkResponsePlan.chunkSize } /** @@ -929,41 +991,7 @@ export class BaseUpload { * @api private */ private async _handleUploadResponse(req: HttpRequest, res: HttpResponse): Promise { - const chunkResponsePlan = tusPlanUploadChunkResponse({ - currentOffset: this._offset, - response: tusReadUploadChunkResponse({ - getHeader: (headerName) => res.getHeader(headerName), - status: res.getStatus(), - }), - size: this._size, - }) - if (chunkResponsePlan.action === 'fail') { - throw new DetailedError(chunkResponsePlan.message, undefined, req, res) - } - - this._emitProgress({ - bytesTotal: this._size, - hasHook: typeof this.options.onProgress === 'function', - phase: 'afterChunkAccepted', - uploadOffset: chunkResponsePlan.offset, - }) - this._emitChunkComplete({ - bytesAccepted: chunkResponsePlan.offset, - bytesTotal: this._size, - chunkSize: chunkResponsePlan.chunkSize, - hasHook: typeof this.options.onChunkComplete === 'function', - phase: 'afterChunkAccepted', - }) - - this._offset = chunkResponsePlan.offset - - if (chunkResponsePlan.action === 'complete') { - // Yay, finally done :) - await this._emitSuccess(res) - return - } - - await this._performUpload() + await this._uploadChunks({ req, res }) } /** diff --git a/lib/upload_chunks_generated.ts b/lib/upload_chunks_generated.ts new file mode 100644 index 000000000..bddfe45a7 --- /dev/null +++ b/lib/upload_chunks_generated.ts @@ -0,0 +1,65 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + +import { tusUploadIsCompleteAfterChunk } from './protocol_generated.js' + +export interface TusUploadChunksUntilCompleteInput { + applyChunkResponse: (exchange: Exchange) => number + emitChunkComplete: (chunkSize: number, offset: number) => void + emitProgressAfterChunkAccepted: (offset: number) => void + emitSuccess: (exchange: Exchange) => Promise + getOffset: () => number + getSize: () => number | null + isAborted: () => boolean + pendingExchange: Exchange | null + performPatchRequest: () => Promise +} + +export async function tusUploadChunksUntilComplete({ + applyChunkResponse, + emitChunkComplete, + emitProgressAfterChunkAccepted, + emitSuccess, + getOffset, + getSize, + isAborted, + pendingExchange, + performPatchRequest, +}: TusUploadChunksUntilCompleteInput): Promise { + let exchange = pendingExchange + + while (true) { + if (exchange != null) { + const acceptedBytes = applyChunkResponse(exchange) + emitProgressAfterChunkAccepted(getOffset()) + emitChunkComplete(acceptedBytes, getOffset()) + if (tusUploadIsCompleteAfterChunk({ offset: getOffset(), size: getSize() })) { + await emitSuccess(exchange) + return + } + } + + if (isAborted()) { + return + } + + let patchError: Error | null = null + try { + exchange = await performPatchRequest() + } catch (error) { + if (isAborted()) { + return + } + + if (!(error instanceof Error)) { + throw error + } + + patchError = error + } + if (patchError != null) { + throw patchError + } + } +} From cdecd5b3a2f8d86422815e4cc46accc352812614 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 23:15:44 +0200 Subject: [PATCH 154/155] Generate the retry scheduling from statement IR _retryOrEmitError's decision pass (abort guard, retryable narrowing, the two-phase retry plan with the onShouldRetry policy round, attempt/offset bookkeeping, schedule-or-emit) now emits from the shared statement-IR walker into lib/retry_scheduling_generated.ts. The retry decisions stay imported from protocol_generated.ts; the upload keeps only the state and timer closures (setTimeout into _retryTimeout, cleared by the abort plan), and the full start() re-entry semantics are unchanged. Co-Authored-By: Claude Fable 5 --- lib/retry_scheduling_generated.ts | 129 ++++++++++++++++++++++++++++++ lib/upload.ts | 87 +++++++------------- 2 files changed, 158 insertions(+), 58 deletions(-) create mode 100644 lib/retry_scheduling_generated.ts diff --git a/lib/retry_scheduling_generated.ts b/lib/retry_scheduling_generated.ts new file mode 100644 index 000000000..288de0b15 --- /dev/null +++ b/lib/retry_scheduling_generated.ts @@ -0,0 +1,129 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + +import { DetailedError } from './DetailedError.js' +import { + tusNextRetryAttempt, + tusPlanRetryAfterError, + tusShouldEvaluateRetryPolicy, + tusShouldResetRetryAttempt, + tusShouldTreatRequestErrorAsRetryable, +} from './protocol_generated.js' + +export interface TusScheduleUploadRetryOrEmitErrorInput { + emitError: (error: Error) => void + error: Error + evaluateRetryPolicy: (error: DetailedError, retryAttempt: number) => boolean + getOffset: () => number + getOffsetBeforeRetry: () => number + getRetryAttempt: () => number + isAborted: () => boolean + retryDelays: readonly number[] | null + scheduleRestart: (delayMs: number) => void + setOffsetBeforeRetry: (offset: number) => void + setRetryAttempt: (retryAttempt: number) => void +} + +export function tusRetryableUploadError(error: Error): DetailedError | null { + if ( + error instanceof DetailedError && + tusShouldTreatRequestErrorAsRetryable({ hasRequestContext: error.originalRequest != null }) + ) { + return error + } + + return null +} + +export function tusEffectiveUploadRetryAttempt({ + offset, + offsetBeforeRetry, + retryAttempt, +}: { + offset: number + offsetBeforeRetry: number + retryAttempt: number +}): number { + return tusShouldResetRetryAttempt({ offset, offsetBeforeRetry }) ? 0 : retryAttempt +} + +export function tusShouldScheduleUploadRetry({ + error, + evaluateRetryPolicy, + retryAttempt, + retryDelays, +}: { + error: Error + evaluateRetryPolicy: (error: DetailedError, retryAttempt: number) => boolean + retryAttempt: number + retryDelays: readonly number[] +}): boolean { + const retryableError = tusRetryableUploadError(error) + let retryPlan = tusPlanRetryAfterError({ + isNetworkError: retryableError != null, + offset: 0, + offsetBeforeRetry: 0, + retryAttempt, + retryDelays, + }) + + if ( + tusShouldEvaluateRetryPolicy({ + hasRetryableError: retryableError != null, + retryPlanAction: retryPlan.action, + }) && + retryableError != null + ) { + retryPlan = tusPlanRetryAfterError({ + isNetworkError: true, + offset: 0, + offsetBeforeRetry: 0, + retryAttempt: retryPlan.retryAttempt, + retryDelays, + shouldRetry: evaluateRetryPolicy(retryableError, retryPlan.retryAttempt), + }) + } + + return retryPlan.action === 'scheduleRetry' +} + +export function tusScheduleUploadRetryOrEmitError({ + emitError, + error, + evaluateRetryPolicy, + getOffset, + getOffsetBeforeRetry, + getRetryAttempt, + isAborted, + retryDelays, + scheduleRestart, + setOffsetBeforeRetry, + setRetryAttempt, +}: TusScheduleUploadRetryOrEmitErrorInput): void { + const activeRetryDelays = retryDelays ?? [] + if (isAborted()) { + return + } + + const effectiveRetryAttempt = tusEffectiveUploadRetryAttempt({ + offset: getOffset(), + offsetBeforeRetry: getOffsetBeforeRetry(), + retryAttempt: getRetryAttempt(), + }) + const scheduleRetry = tusShouldScheduleUploadRetry({ + error, + evaluateRetryPolicy, + retryAttempt: effectiveRetryAttempt, + retryDelays: activeRetryDelays, + }) + if (!scheduleRetry) { + setRetryAttempt(effectiveRetryAttempt) + emitError(error) + return + } + + setRetryAttempt(tusNextRetryAttempt({ retryAttempt: effectiveRetryAttempt })) + setOffsetBeforeRetry(getOffset()) + scheduleRestart(activeRetryDelays[effectiveRetryAttempt]) +} diff --git a/lib/upload.ts b/lib/upload.ts index 18f0581a8..b114dcefa 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -44,7 +44,6 @@ import { tusPlanResumeOffsetResponse, tusPlanResumeResponseStatus, tusPlanResumeUploadRequest, - tusPlanRetryAfterError, tusPlanSingleUploadStart, tusPlanStoredUploadRecord, tusPlanSuccessEvent, @@ -61,10 +60,8 @@ import { tusReadUploadCreationResponse, tusReadUploadOffsetResponse, tusResolveUploadLocation, - tusShouldEvaluateRetryPolicy, tusShouldSendUploadBodyDuringCreation, tusShouldSuppressErrorAfterAbort, - tusShouldTreatRequestErrorAsRetryable, tusShouldUseCustomRetryPolicy, tusTerminateUploadRequestPlan, tusUploadBodyHeaders, @@ -72,6 +69,7 @@ import { tusUrlStorageCreationTime, tusValidateUploadStart, } from './protocol_generated.js' +import { tusScheduleUploadRetryOrEmitError } from './retry_scheduling_generated.js' import { tusTerminateUploadWithRetry } from './terminate_generated.js' import { tusUploadChunksUntilComplete } from './upload_chunks_generated.js' import { uuid } from './uuid.js' @@ -511,50 +509,37 @@ export class BaseUpload { } } + /** + * Run the generated retry-scheduling decision pass: either schedule a full start() re-entry + * through the retry timer or emit the error to the user. The abort guard, the retry decisions, + * and the attempt/offset bookkeeping live in the generated module; this method only wires the + * upload's state and timer closures. + * + * @api private + */ private _retryOrEmitError(err: Error): void { - // Do not retry if explicitly aborted - if (this._aborted) return - - const retryableErr = getRetryableError(err) - const retryInput = { - isNetworkError: retryableErr != null, - offset: this._offset, - offsetBeforeRetry: this._offsetBeforeRetry, + tusScheduleUploadRetryOrEmitError({ + emitError: (error) => this._emitError(error), + error: err, + evaluateRetryPolicy: (error, retryAttempt) => + shouldRetryByPolicy(error, retryAttempt, this.options), + getOffset: () => this._offset, + getOffsetBeforeRetry: () => this._offsetBeforeRetry, + getRetryAttempt: () => this._retryAttempt, + isAborted: () => this._aborted, retryDelays: this.options.retryDelays, - } - let retryPlan = tusPlanRetryAfterError({ - ...retryInput, - retryAttempt: this._retryAttempt, + scheduleRestart: (delayMs) => { + this._retryTimeout = setTimeout(() => { + this.start() + }, delayMs) + }, + setOffsetBeforeRetry: (offset) => { + this._offsetBeforeRetry = offset + }, + setRetryAttempt: (retryAttempt) => { + this._retryAttempt = retryAttempt + }, }) - this._retryAttempt = retryPlan.retryAttempt - - if ( - tusShouldEvaluateRetryPolicy({ - hasRetryableError: retryableErr != null, - retryPlanAction: retryPlan.action, - }) && - retryableErr != null - ) { - retryPlan = tusPlanRetryAfterError({ - ...retryInput, - retryAttempt: retryPlan.retryAttempt, - shouldRetry: shouldRetryByPolicy(retryableErr, retryPlan.retryAttempt, this.options), - }) - this._retryAttempt = retryPlan.retryAttempt - } - - if (retryPlan.action === 'scheduleRetry') { - this._retryAttempt = retryPlan.nextRetryAttempt - this._offsetBeforeRetry = retryPlan.offsetBeforeRetry - - this._retryTimeout = setTimeout(() => { - this.start() - }, retryPlan.delay) - return - } - - // If we are not retrying, emit the error to the user. - this._emitError(err) } /** @@ -1145,20 +1130,6 @@ function isOnline(): boolean { return tusDefaultRetryOnlineStatus({ platformOnline }) } -/** - * Extract errors that originated from a request. Only these can safely be retried. - */ -function getRetryableError(err: Error): DetailedError | null { - if ( - err instanceof DetailedError && - tusShouldTreatRequestErrorAsRetryable({ hasRequestContext: err.originalRequest != null }) - ) { - return err - } - - return null -} - function shouldRetryByPolicy( err: DetailedError, retryAttempt: number, From d01b9711a1adabbdf02119eb62c970cafc15d87d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 23:59:10 +0200 Subject: [PATCH 155/155] Generate the upload lifecycle from statement IR _prepareAndStartUpload/_startSingleUpload/_createUpload/_resumeUpload's orchestration (fingerprint, source open, size derivation, the parallel branch point, the abort reset, resume-or-create dispatch, the creation and resume flows with their settlement/emission/storage order, and the chunk-loop entries) now emits from the shared statement-IR walker into lib/upload_lifecycle_generated.ts. The request/response decisions stay imported from protocol_generated.ts; the upload keeps only the host seams (the per-attempt creation/HEAD transports, the settlements over the generated plans, and the state closures). The 1-line _performUpload/_handleUploadResponse wrappers collapsed into the generated flows' uploadChunks entries; start()'s floating catch and validation stay the handwritten wrapper on purpose (the no-await floating-promise semantics and non-Error normalization are host idioms). Co-Authored-By: Claude Fable 5 --- lib/upload.ts | 318 +++++++++++++++++------------- lib/upload_lifecycle_generated.ts | 142 +++++++++++++ 2 files changed, 325 insertions(+), 135 deletions(-) create mode 100644 lib/upload_lifecycle_generated.ts diff --git a/lib/upload.ts b/lib/upload.ts index b114dcefa..0ca6ab3e6 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -51,7 +51,6 @@ import { tusPlanTerminateUploadRequest, tusPlanUploadChunkRequest, tusPlanUploadChunkResponse, - tusPlanUploadCompletionAfterOffset, tusPlanUploadCreationRequest, tusPlanUploadCreationResponse, tusPlanUploadStorage, @@ -72,6 +71,11 @@ import { import { tusScheduleUploadRetryOrEmitError } from './retry_scheduling_generated.js' import { tusTerminateUploadWithRetry } from './terminate_generated.js' import { tusUploadChunksUntilComplete } from './upload_chunks_generated.js' +import { + tusCreateUploadFlow, + tusPrepareAndStartUpload, + tusResumeUploadFlow, +} from './upload_lifecycle_generated.js' import { uuid } from './uuid.js' // The request/response pair a chunk upload attempt settles through the generated chunk loop: @@ -81,6 +85,12 @@ interface TusChunkExchange { res: HttpResponse } +// The creation exchange additionally carries the endpoint the POST went to, so the response +// settlement can resolve the Location header against the request URL. +interface TusCreationExchange extends TusChunkExchange { + endpoint: string +} + export const defaultOptions = { endpoint: undefined, @@ -228,35 +238,62 @@ export class BaseUpload { }) } + /** + * Run the generated preparation flow: fingerprint, source open, size derivation, the parallel + * branch point, the abort-flag reset, and the resume-or-create dispatch. The step order lives + * in the generated module; the closures own the upload's state. + * + * @api private + */ private async _prepareAndStartUpload(): Promise { - this._fingerprint = await this.options.fingerprint(this.file, this.options) - log(tusPlanPreparedFingerprintLog({ fingerprint: this._fingerprint }).message) + await tusPrepareAndStartUpload({ + computeFingerprint: async () => { + this._fingerprint = await this.options.fingerprint(this.file, this.options) + log(tusPlanPreparedFingerprintLog({ fingerprint: this._fingerprint }).message) + }, + createUpload: () => this._createUpload(), + hasSource: () => this._source != null, + openSource: async () => { + this._source = await this.options.fileReader.openFile(this.file, this.options.chunkSize) + }, + prepareUploadSize: () => { + const preparedUploadSizePlan = tusPlanPreparedUploadSize({ + sourceSize: this._source?.size, + uploadLengthDeferred: this._uploadLengthDeferred, + uploadSize: this.options.uploadSize, + }) + if (!preparedUploadSizePlan.ok) { + throw new Error(preparedUploadSizePlan.message) + } - if (this._source == null) { - this._source = await this.options.fileReader.openFile(this.file, this.options.chunkSize) - } + this._size = preparedUploadSizePlan.size + }, + // Reset the aborted flag when the upload is started or else the chunk loop would stop + // before sending a request if the upload has been aborted previously. + resetAborted: () => { + this._aborted = false + }, + resolveStartMode: () => { + const plan = tusPlanSingleUploadStart({ + currentUrl: this.url, + uploadUrl: this.options.uploadUrl, + }) + log(plan.logMessage) - const preparedUploadSizePlan = tusPlanPreparedUploadSize({ - sourceSize: this._source.size, - uploadLengthDeferred: this._uploadLengthDeferred, - uploadSize: this.options.uploadSize, - }) - if (!preparedUploadSizePlan.ok) { - throw new Error(preparedUploadSizePlan.message) - } - this._size = preparedUploadSizePlan.size + if (plan.action === 'resumeConfigured') { + this.url = plan.url + } - const preparedUploadModePlan = tusPlanPreparedUploadMode({ - hasParallelUploadUrls: this._parallelUploadUrls != null, - parallelUploads: this.options.parallelUploads, + return plan.action !== 'create' + }, + resumeUpload: () => this._resumeUpload(), + shouldUploadInParallel: () => + tusPlanPreparedUploadMode({ + hasParallelUploadUrls: this._parallelUploadUrls != null, + parallelUploads: this.options.parallelUploads, + }).action === 'parallel', + startParallelUpload: () => this._startParallelUpload(), }) - - if (preparedUploadModePlan.action === 'parallel') { - await this._startParallelUpload() - return - } - - await this._startSingleUpload() } /** @@ -410,36 +447,6 @@ export class BaseUpload { await this._emitSuccess(res) } - /** - * Initiate the uploading procedure for a non-parallel upload. Here the entire file is - * uploaded in a sequential matter. - * - * @api private - */ - private async _startSingleUpload(): Promise { - // Reset the aborted flag when the upload is started or else the - // _performUpload will stop before sending a request if the upload has been - // aborted previously. - this._aborted = false - - const plan = tusPlanSingleUploadStart({ - currentUrl: this.url, - uploadUrl: this.options.uploadUrl, - }) - log(plan.logMessage) - - if (plan.action === 'resumeCurrent') { - return await this._resumeUpload() - } - - if (plan.action === 'resumeConfigured') { - this.url = plan.url - return await this._resumeUpload() - } - - return await this._createUpload() - } - /** * Abort any running request and stop the current upload. After abort is called, no event * handler will be invoked anymore. You can use the `start` method to resume the upload @@ -617,13 +624,36 @@ export class BaseUpload { } /** - * Create a new upload using the creation extension by sending a POST - * request to the endpoint. After successful creation the file will be - * uploaded + * Create a new upload using the creation extension by sending a POST request to the endpoint + * and hand the settled exchange into the generated chunk loop. The step order — response + * settlement, the upload-url-available emission, the empty-upload completion, URL storage, + * the chunk-loop entry — lives in the generated module. * * @api private */ private async _createUpload(): Promise { + await tusCreateUploadFlow({ + applyCreationResponse: (exchange) => this._applyCreationResponse(exchange), + emitSuccess: (exchange) => this._emitSuccess(exchange.res), + emitUploadUrlAvailable: () => this._emitUploadUrlAvailable('createUpload'), + performCreationRequest: () => this._performCreationRequest(), + saveUploadInUrlStorage: () => this._saveUploadInUrlStorage(), + setOffset: (offset) => { + this._offset = offset + }, + uploadChunks: (pendingExchange) => this._uploadChunks(pendingExchange), + uploadDataDuringCreation: this.options.uploadDataDuringCreation, + }) + } + + /** + * Send the upload creation POST — optionally carrying the first chunk when the + * uploadDataDuringCreation option asks for it — and surface any failure as a DetailedError. + * This is the per-attempt creation transport the generated creation flow calls. + * + * @api private + */ + private async _performCreationRequest(): Promise { const creationRequestPlan = tusPlanUploadCreationRequest({ endpoint: this.options.endpoint, size: this._size, @@ -666,6 +696,16 @@ export class BaseUpload { throw new DetailedError(creationRequestPlan.requestErrorMessage, err, req, undefined) } + return { endpoint: creationRequestPlan.endpoint, req, res } + } + + /** + * Read the creation response, fail on unusable responses, resolve the upload's location, and + * report whether the created upload is already complete (an empty, non-deferred upload). + * + * @api private + */ + private _applyCreationResponse({ endpoint, req, res }: TusCreationExchange): boolean { const creationResponsePlan = tusPlanUploadCreationResponse({ followUp: 'patchIfNonempty', response: tusReadUploadCreationResponse({ @@ -680,42 +720,62 @@ export class BaseUpload { this.url = tusResolveUploadLocation({ location: creationResponsePlan.location, - requestUrl: creationRequestPlan.endpoint, + requestUrl: endpoint, }) log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) - await this._emitUploadUrlAvailable('createUpload') - - if (creationResponsePlan.action === 'complete') { - // Nothing to upload and file was successfully created - await this._emitSuccess(res) - return - } - - await this._saveUploadInUrlStorage() - - if (this.options.uploadDataDuringCreation) { - await this._handleUploadResponse(req, res) - } else { - this._offset = 0 - await this._performUpload() - } + return creationResponsePlan.action === 'complete' } /** - * Try to resume an existing upload. First a HEAD request will be sent - * to retrieve the offset. If the request fails a new upload will be - * created. In the case of a successful response the file will be uploaded. + * Try to resume an existing upload: HEAD for the offset and fall back to creating a new + * upload when the response is not resumable. The step order — status settlement, offset + * application, the upload-url-available emission, URL storage, the already-complete exit, the + * chunk-loop entry — lives in the generated module. * * @api private */ private async _resumeUpload(): Promise { + await tusResumeUploadFlow({ + applyResumeOffset: (exchange) => this._applyResumeOffset(exchange), + clearUploadUrl: () => { + this.url = null + }, + createUpload: () => this._createUpload(), + emitProgressAfterResumeAlreadyComplete: (length) => { + this._emitProgress({ + hasHook: typeof this.options.onProgress === 'function', + phase: 'afterResumeAlreadyComplete', + uploadLength: length, + }) + }, + emitSuccess: (exchange) => this._emitSuccess(exchange.res), + emitUploadUrlAvailable: () => this._emitUploadUrlAvailable('resumeUpload'), + performHeadRequest: () => this._performResumeHeadRequest(), + readUploadLength: (exchange) => this._readResumeUploadLength(exchange), + saveUploadInUrlStorage: () => this._saveUploadInUrlStorage(), + setOffset: (offset) => { + this._offset = offset + }, + settleResumeStatus: (exchange) => this._settleResumeStatus(exchange), + uploadChunks: (pendingExchange) => this._uploadChunks(pendingExchange), + }) + } + + /** + * Send the HEAD request that retrieves the remote upload's offset and surface any failure as + * a DetailedError. This is the per-attempt resume transport the generated resume flow calls. + * + * @api private + */ + private async _performResumeHeadRequest(): Promise { const resumeRequestPlan = tusPlanResumeUploadRequest({ uploadUrl: this.url, }) if (!resumeRequestPlan.ok) { throw new Error(resumeRequestPlan.message) } + const req = this._openRequest( tusGetUploadOffsetRequestPlan({ protocol: this.options.protocol, @@ -723,9 +783,8 @@ export class BaseUpload { }), ) - let res: HttpResponse try { - res = await this._sendRequest(req) + return { req, res: await this._sendRequest(req) } } catch (err) { if (!(err instanceof Error)) { throw new Error(tusNonErrorThrownValueMessage({ value: err })) @@ -733,72 +792,71 @@ export class BaseUpload { throw new DetailedError(resumeRequestPlan.requestErrorMessage, err, req, undefined) } + } - const status = res.getStatus() + /** + * Apply the resume status plan: remove the stored upload when planned, fail on unusable + * responses, and report whether a new upload must be created instead (the 4xx + * fallback-to-create path). + * + * @api private + */ + private async _settleResumeStatus({ req, res }: TusChunkExchange): Promise { const responseStatusPlan = tusPlanResumeResponseStatus({ hasEndpoint: this.options.endpoint != null, - status, + status: res.getStatus(), }) - if (responseStatusPlan.action !== 'readOffset') { - if (responseStatusPlan.removeStoredUpload) { - await this._removeFromUrlStorage() - } + if (responseStatusPlan.action === 'readOffset') { + return false + } - if (responseStatusPlan.action === 'fail') { - throw new DetailedError(responseStatusPlan.message, undefined, req, res) - } + if (responseStatusPlan.removeStoredUpload) { + await this._removeFromUrlStorage() + } - this.url = null - await this._createUpload() - return + if (responseStatusPlan.action === 'fail') { + throw new DetailedError(responseStatusPlan.message, undefined, req, res) } - const offsetResponse = tusReadUploadOffsetResponse({ - getHeader: (headerName) => res.getHeader(headerName), - protocol: this.options.protocol, - status, - }) + return true + } + + /** + * Read the offset response, fail on unusable responses, sync the deferred-length state, and + * return the server-side offset the upload continues from. + * + * @api private + */ + private _applyResumeOffset({ req, res }: TusChunkExchange): number { const offsetResponsePlan = tusPlanResumeOffsetResponse({ - response: offsetResponse, + response: tusReadUploadOffsetResponse({ + getHeader: (headerName) => res.getHeader(headerName), + protocol: this.options.protocol, + status: res.getStatus(), + }), }) if (offsetResponsePlan.action === 'fail') { throw new DetailedError(offsetResponsePlan.message, undefined, req, res) } - const offset = offsetResponsePlan.offset - const length = offsetResponsePlan.length this._uploadLengthDeferred = offsetResponsePlan.uploadLengthDeferred - - await this._emitUploadUrlAvailable('resumeUpload') - - await this._saveUploadInUrlStorage() - - // Upload has already been completed and we do not need to send additional - // data to the server - const completionPlan = tusPlanUploadCompletionAfterOffset({ length, offset }) - if (completionPlan.complete) { - this._emitProgress({ - hasHook: typeof this.options.onProgress === 'function', - phase: 'afterResumeAlreadyComplete', - uploadLength: completionPlan.length, - }) - await this._emitSuccess(res) - return - } - - this._offset = offset - await this._performUpload() + return offsetResponsePlan.offset } /** - * Start uploading the file using PATCH requests. The file will be divided - * into chunks as specified in the chunkSize option. During the upload - * the onProgress event handler may be invoked multiple times. + * Read the optional Upload-Length off the offset response — the resume completion check's + * input next to the discovered offset. * * @api private */ - private async _performUpload(): Promise { - await this._uploadChunks(null) + private _readResumeUploadLength({ res }: TusChunkExchange): number | null { + const offsetResponse = tusReadUploadOffsetResponse({ + getHeader: (headerName) => res.getHeader(headerName), + protocol: this.options.protocol, + status: res.getStatus(), + }) + + return offsetResponse.ok ? offsetResponse.length : null } /** @@ -969,16 +1027,6 @@ export class BaseUpload { return await this._sendRequest(req, value) } - /** - * _handleUploadResponse is used by requests that haven been sent using _addChunkToRequest - * and already have received a response. - * - * @api private - */ - private async _handleUploadResponse(req: HttpRequest, res: HttpResponse): Promise { - await this._uploadChunks({ req, res }) - } - /** * Create a new HTTP request object with the given method and URL. * diff --git a/lib/upload_lifecycle_generated.ts b/lib/upload_lifecycle_generated.ts new file mode 100644 index 000000000..a567bd817 --- /dev/null +++ b/lib/upload_lifecycle_generated.ts @@ -0,0 +1,142 @@ +// This file is generated from Transloadit API2 TUS protocol contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the protocol contract generator so all TUS clients stay in sync. + +import { tusPlanUploadCompletionAfterOffset } from './protocol_generated.js' + +export interface TusPrepareAndStartUploadInput { + computeFingerprint: () => Promise + createUpload: () => Promise + hasSource: () => boolean + openSource: () => Promise + prepareUploadSize: () => void + resetAborted: () => void + resolveStartMode: () => boolean + resumeUpload: () => Promise + shouldUploadInParallel: () => boolean + startParallelUpload: () => Promise +} + +export async function tusPrepareAndStartUpload({ + computeFingerprint, + createUpload, + hasSource, + openSource, + prepareUploadSize, + resetAborted, + resolveStartMode, + resumeUpload, + shouldUploadInParallel, + startParallelUpload, +}: TusPrepareAndStartUploadInput): Promise { + await computeFingerprint() + if (!hasSource()) { + await openSource() + } + + prepareUploadSize() + if (shouldUploadInParallel()) { + await startParallelUpload() + return + } + + resetAborted() + const shouldResume = resolveStartMode() + if (shouldResume) { + await resumeUpload() + return + } + + await createUpload() +} + +export interface TusCreateUploadFlowInput { + applyCreationResponse: (exchange: Exchange) => boolean + emitSuccess: (exchange: Exchange) => Promise + emitUploadUrlAvailable: () => Promise + performCreationRequest: () => Promise + saveUploadInUrlStorage: () => Promise + setOffset: (offset: number) => void + uploadChunks: (pendingExchange: Exchange | null) => Promise + uploadDataDuringCreation: boolean +} + +export async function tusCreateUploadFlow({ + applyCreationResponse, + emitSuccess, + emitUploadUrlAvailable, + performCreationRequest, + saveUploadInUrlStorage, + setOffset, + uploadChunks, + uploadDataDuringCreation, +}: TusCreateUploadFlowInput): Promise { + const exchange = await performCreationRequest() + const creationComplete = applyCreationResponse(exchange) + await emitUploadUrlAvailable() + if (creationComplete) { + await emitSuccess(exchange) + return + } + + await saveUploadInUrlStorage() + if (uploadDataDuringCreation) { + await uploadChunks(exchange) + return + } + + setOffset(0) + await uploadChunks(null) +} + +export interface TusResumeUploadFlowInput { + applyResumeOffset: (exchange: Exchange) => number + clearUploadUrl: () => void + createUpload: () => Promise + emitProgressAfterResumeAlreadyComplete: (length: number) => void + emitSuccess: (exchange: Exchange) => Promise + emitUploadUrlAvailable: () => Promise + performHeadRequest: () => Promise + readUploadLength: (exchange: Exchange) => number | null + saveUploadInUrlStorage: () => Promise + setOffset: (offset: number) => void + settleResumeStatus: (exchange: Exchange) => Promise + uploadChunks: (pendingExchange: Exchange | null) => Promise +} + +export async function tusResumeUploadFlow({ + applyResumeOffset, + clearUploadUrl, + createUpload, + emitProgressAfterResumeAlreadyComplete, + emitSuccess, + emitUploadUrlAvailable, + performHeadRequest, + readUploadLength, + saveUploadInUrlStorage, + setOffset, + settleResumeStatus, + uploadChunks, +}: TusResumeUploadFlowInput): Promise { + const exchange = await performHeadRequest() + const shouldCreateNewUpload = await settleResumeStatus(exchange) + if (shouldCreateNewUpload) { + clearUploadUrl() + await createUpload() + return + } + + const offset = applyResumeOffset(exchange) + const length = readUploadLength(exchange) + await emitUploadUrlAvailable() + await saveUploadInUrlStorage() + const uploadCompletion = tusPlanUploadCompletionAfterOffset({ length, offset }) + if (uploadCompletion.complete) { + emitProgressAfterResumeAlreadyComplete(uploadCompletion.length) + await emitSuccess(exchange) + return + } + + setOffset(offset) + await uploadChunks(null) +}