diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b55dcc41f..46e13d45e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,17 @@ jobs: deno-version: latest - name: Install dependencies run: npm ci + - name: Configure Puppeteer browser + if: matrix.suite == 'puppeteer' + run: | + chrome_bin="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium-browser || true)" + if [ -z "$chrome_bin" ]; then + rm -rf ~/.cache/puppeteer/chrome + npx puppeteer browsers install chrome + chrome_bin="$(node -e "process.stdout.write(require('puppeteer').executablePath())")" + fi + test -x "$chrome_bin" + echo "CHROME_BIN=$chrome_bin" >> "$GITHUB_ENV" - name: Build run: npm run build - name: Test diff --git a/examples/api2-devdock-shared/scenario.js b/examples/api2-devdock-shared/scenario.js new file mode 100644 index 000000000..d7303b560 --- /dev/null +++ b/examples/api2-devdock-shared/scenario.js @@ -0,0 +1,945 @@ +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 { + TUS_DEFAULT_CLIENT_PROTOCOL, + tusAbortErrorDescriptor, + tusRequestHeadersForProtocol, +} from '../../lib.esm/protocol_generated.js' + +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 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.headers) { + options.headers = uploadConfig.headers + } + + if (uploadConfig.addRequestId === true) { + options.addRequestId = true + } + + if (uploadConfig.uploadDataDuringCreation === true) { + options.uploadDataDuringCreation = true + } + + if (uploadConfig.uploadLengthDeferred === true) { + options.uploadLengthDeferred = true + } + + 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)}`) + } + + 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 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 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 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 +} + +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 { bytes: null, size: null } + } + + if (body instanceof Blob) { + const bytes = new Uint8Array(await body.arrayBuffer()) + return { bytes, size: bytes.byteLength } + } + + if (body instanceof ArrayBuffer) { + const bytes = new Uint8Array(body) + return { bytes, size: bytes.byteLength } + } + + if (ArrayBuffer.isView(body)) { + const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength) + return { bytes, size: bytes.byteLength } + } + + if (typeof body.length === 'number') { + const bytes = Buffer.isBuffer(body) ? body : null + return { bytes, size: body.length } + } + + fail(`TUS conformance scenario cannot measure request body ${typeof body}`) +} + +function chunkBytes(chunk) { + if (typeof chunk === 'string') { + return Buffer.from(chunk) + } + + if (chunk instanceof ArrayBuffer) { + return new Uint8Array(chunk) + } + + if (ArrayBuffer.isView(chunk)) { + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) + } + + fail(`TUS conformance scenario cannot measure request body chunk ${typeof chunk}`) +} + +async function streamBodySnapshot(body, progressHandler) { + const parts = [] + for await (const chunk of body) { + const bytes = chunkBytes(chunk) + parts.push(bytes) + progressHandler(parts.reduce((sum, part) => sum + part.byteLength, 0)) + } + + const bytes = concatBytes(parts) + return { bytes, size: bytes.byteLength } +} + +async function bodySnapshot(body, progressHandler) { + if (body instanceof Readable) { + return { ...(await streamBodySnapshot(body, progressHandler)), isStream: true } + } + + return { ...(await fixedBodySnapshot(body)), isStream: false } +} + +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 + }, + }) +} + +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) { + options[entry.key] = entry.value + } + + 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 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 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') { + 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 { + 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({ events, inputContent, observed, onRequestStart, requestPlan, url }) { + this.headers = {} + this.events = events + this.inputContent = inputContent + this.observed = observed + this.onRequestStart = onRequestStart + this.requestPlan = requestPlan + this.url = url + this.abortReject = null + this.abortRecorded = false + this.aborted = false + 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 + } + + async send(body = null) { + 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])}`, + ) + } + } + + 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 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}`, + ) + } + + 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) + + 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)) + } + + if (!this.requestPlan.response) { + fail('TUS conformance scenario request has no response or error plan') + } + + return Promise.resolve(new ContractResponse(this.requestPlan.response)) + } + + 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 + } +} + +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 + : [] + 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, + 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, { 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: [], + } + } + + 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({ + events: this.events, + inputContent: this.inputContent, + observed: this.observed, + onRequestStart: this.onRequestStart, + 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)) { + fail('scenario upload is missing a termination plan') + } + + 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) { + 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 new file mode 100644 index 000000000..ab52f0a7d --- /dev/null +++ b/examples/api2-devdock-transloadit-assembly-upload/main.js @@ -0,0 +1,39 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + scenarioBytes, + tusUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithTus(scenario, createResponse) { + 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('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 uploadWithTus(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) +}) 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) +}) 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) +}) 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) +}) 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) +}) 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) +}) 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..4c0c47e85 --- /dev/null +++ b/examples/api2-devdock-tus-node-path-input-source/main.js @@ -0,0 +1,82 @@ +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 = (payload) => { + if (tusConformanceScenarioWantsEvent(conformanceScenario, 'success')) { + events.push({ kind: 'success' }) + } + resolve(payload) + } + upload.start() + }) + + 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 new file mode 100644 index 000000000..80e45690e --- /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 = await 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-parallel-upload-concat/main.js b/examples/api2-devdock-tus-parallel-upload-concat/main.js new file mode 100644 index 000000000..57a6434b8 --- /dev/null +++ b/examples/api2-devdock-tus-parallel-upload-concat/main.js @@ -0,0 +1,99 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + absentHeaderPresence, + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceExpectedEventSequence, + tusConformanceUploadInput, + tusConformanceUploadOptions, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithParallelConcat(conformanceScenario) { + const rawEvents = [] + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(conformanceScenario, { events: rawEvents }) + const upload = new Upload(content, { + ...tusConformanceUploadOptions(conformanceScenario), + httpStack, + onChunkComplete: (chunkSize, bytesAccepted, bytesTotal) => { + rawEvents.push({ + bytesAccepted, + bytesTotal, + chunkSize, + kind: 'chunk-complete', + }) + }, + onProgress: (bytesSent, bytesTotal) => { + rawEvents.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}`, + ) + } + const events = tusConformanceExpectedEventSequence(conformanceScenario, rawEvents) + + return { + absentHeaderPresence: absentHeaderPresence( + conformanceScenario, + httpStack.observed.requestHeaders, + ), + completionKind, + errorCalled, + eventCount: events.length, + events, + rawEventCount: rawEvents.length, + 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 new file mode 100644 index 000000000..325131cd3 --- /dev/null +++ b/examples/api2-devdock-tus-protocol-version-selection/main.js @@ -0,0 +1,76 @@ +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 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) +}) 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..8b5d24760 --- /dev/null +++ b/examples/api2-devdock-tus-relative-location-resolution/main.js @@ -0,0 +1,58 @@ +import { Upload } from '../../lib.esm/node/index.js' +import { + fail, + loadScenario, + requireTusConformanceScenario, + TusConformanceHttpStack, + tusConformanceInputOptions, + tusConformanceUploadInput, + writeJsonResult, +} from '../api2-devdock-shared/scenario.js' + +async function uploadWithRelativeLocationResolution(conformanceScenario) { + const inputOptions = tusConformanceInputOptions(conformanceScenario) + const content = await tusConformanceUploadInput(conformanceScenario) + const httpStack = new TusConformanceHttpStack(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) +}) 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) +}) 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) +}) 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..f164a67ba --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.js @@ -0,0 +1,165 @@ +import { mkdtemp, readFile, 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), + removeFingerprintOnSuccess: resume.removeFingerprintOnSuccess, + 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, + storedUploadKey: previousUpload.urlStorageKey, + 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 }) + 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 }) + } +} + +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) +}) 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) +}) 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) +}) 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) +}) 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) +}) 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) +}) 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) +}) diff --git a/lib/DetailedError.ts b/lib/DetailedError.ts index 93ffd8f50..cc6381b9f 100644 --- a/lib/DetailedError.ts +++ b/lib/DetailedError.ts @@ -1,4 +1,11 @@ import type { HttpRequest, HttpResponse } from './options.js' +import type { TusDetailedErrorRequestContext } from './protocol_generated.js' +import { + TUS_REQUEST_ID_HEADER_NAME, + tusDetailedErrorEmptyResponseBody, + tusDetailedErrorMessage, + tusDetailedErrorMissingValue, +} from './protocol_generated.js' export class DetailedError extends Error { originalRequest?: HttpRequest @@ -14,18 +21,23 @@ export class DetailedError extends Error { this.originalResponse = res this.causingError = causingErr - if (causingErr != null) { - message += `, caused by ${causingErr.toString()}` - } - + let requestContext: TusDetailedErrorRequestContext | undefined if (req != null) { - const requestId = req.getHeader('X-Request-ID') || 'n/a' - const method = req.getMethod() - const url = req.getURL() - const status = res ? res.getStatus() : 'n/a' - const body = res ? res.getBody() || '' : 'n/a' - message += `, originated from request (method: ${method}, url: ${url}, response code: ${status}, response text: ${body}, request id: ${requestId})` + requestContext = { + body: res + ? res.getBody() || tusDetailedErrorEmptyResponseBody() + : tusDetailedErrorMissingValue(), + method: req.getMethod(), + requestId: req.getHeader(TUS_REQUEST_ID_HEADER_NAME) || tusDetailedErrorMissingValue(), + status: res ? res.getStatus() : tusDetailedErrorMissingValue(), + url: req.getURL(), + } } - this.message = message + + this.message = tusDetailedErrorMessage({ + baseMessage: message, + cause: causingErr?.toString(), + requestContext, + }) } } diff --git a/lib/browser/BrowserFileReader.ts b/lib/browser/BrowserFileReader.ts index d7b8ca09d..c726432a7 100644 --- a/lib/browser/BrowserFileReader.ts +++ b/lib/browser/BrowserFileReader.ts @@ -1,8 +1,11 @@ -import { - openFile as openBaseFile, - supportedTypes as supportedBaseTypes, -} from '../commonFileReader.js' +import { openFile as openBaseFile } from '../commonFileReader.js' import type { FileReader, FileSource, UploadInput } from '../options.js' +import { + tusCommonSupportedFileSourceTypes, + tusReactNativeUriBlobFetchFailedMessage, + tusReactNativeUriUnsupportedMessage, + tusUnsupportedSourceTypeMessage, +} from '../protocol_generated.js' import { isReactNativeFile, isReactNativePlatform } from '../reactnative/isReactNative.js' import { uriToBlob } from '../reactnative/uriToBlob.js' import { BlobFileSource } from '../sources/BlobFileSource.js' @@ -15,16 +18,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 })) } } @@ -32,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/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..6e1bbcb89 100644 --- a/lib/browser/XHRHttpStack.ts +++ b/lib/browser/XHRHttpStack.ts @@ -6,6 +6,10 @@ import type { HttpStack, SliceType, } from '../options.js' +import { + tusAbortErrorDescriptor, + tusHttpStackNodeReadableBodyUnsupportedMessage, +} from '../protocol_generated.js' export class XHRHttpStack implements HttpStack { createRequest(method: string, url: string): HttpRequest { @@ -68,7 +72,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' }), ) } @@ -82,7 +86,8 @@ class XHRRequest implements HttpRequest { } this._xhr.onabort = () => { - reject(new DOMException('Request was aborted', 'AbortError')) + const error = tusAbortErrorDescriptor() + reject(new DOMException(error.message, error.name)) } this._xhr.send(body) 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/browser/urlStorage.ts b/lib/browser/urlStorage.ts index e20f76798..135bbeef5 100644 --- a/lib/browser/urlStorage.ts +++ b/lib/browser/urlStorage.ts @@ -1,4 +1,15 @@ import type { PreviousUpload, UrlStorage } from '../options.js' +import { + tusIsWebStorageUnavailableError, + tusShouldIgnoreMalformedStoredUpload, + tusUrlStorageAllUploadsPrefix, + tusUrlStorageFingerprintPrefix, + tusUrlStorageId, + tusUrlStorageKey, + tusUrlStorageMissingItemMessage, + tusUrlStorageMissingKeyMessage, + tusWebStorageProbeKey, +} from '../protocol_generated.js' let hasStorage = false try { @@ -9,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) @@ -17,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 @@ -29,12 +39,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) } @@ -44,8 +54,8 @@ export class WebStorageUrlStorage implements UrlStorage { } addUpload(fingerprint: string, upload: PreviousUpload): Promise { - const id = Math.round(Math.random() * 1e12) - const key = `tus::${fingerprint}::${id}` + const id = tusUrlStorageId({ randomValue: Math.random() }) + const key = tusUrlStorageKey({ fingerprint, id }) localStorage.setItem(key, JSON.stringify(upload)) return Promise.resolve(key) @@ -57,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 @@ -65,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/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/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/node/FileUrlStorage.ts b/lib/node/FileUrlStorage.ts index 76e3ed5b4..487ef83fa 100644 --- a/lib/node/FileUrlStorage.ts +++ b/lib/node/FileUrlStorage.ts @@ -1,6 +1,12 @@ import { readFile, writeFile } from 'node:fs/promises' import { lock } from 'proper-lockfile' import type { PreviousUpload, UrlStorage } from '../options.js' +import { + tusUrlStorageAllUploadsPrefix, + tusUrlStorageFingerprintPrefix, + tusUrlStorageId, + tusUrlStorageKey, +} from '../protocol_generated.js' export const canStoreURLs = true @@ -12,11 +18,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 { @@ -24,8 +30,8 @@ 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 id = tusUrlStorageId({ randomValue: Math.random() }) + const key = tusUrlStorageKey({ fingerprint, id }) await this._setItem(key, upload) return key 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/NodeHttpStack.ts b/lib/node/NodeHttpStack.ts index 6a5fb0a47..e0638b2ac 100644 --- a/lib/node/NodeHttpStack.ts +++ b/lib/node/NodeHttpStack.ts @@ -10,6 +10,12 @@ import type { HttpStack, SliceType, } from '../options.js' +import { + tusAbortErrorDescriptor, + tusHttpStackProgressThrottle, + tusNodeHttpStackMissingStatusCodeMessage, + tusNodeHttpStackUnsupportedBodyTypeMessage, +} from '../protocol_generated.js' export class NodeHttpStack implements HttpStack { private _requestOptions: http.RequestOptions @@ -80,9 +86,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), + }), ) } } @@ -144,8 +151,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() } @@ -166,7 +175,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 +197,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. @@ -204,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, }) } @@ -235,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/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/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/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 new file mode 100644 index 000000000..0a9fecfbf --- /dev/null +++ b/lib/protocol_generated.ts @@ -0,0 +1,3270 @@ +// 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' +import URL from 'url-parse' + +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', + 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_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', + 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', + TUS_EXTENSION: 'Tus-Extension', + 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 = { + APPLICATION_OFFSET_OCTET_STREAM: 'application/offset+octet-stream', + APPLICATION_PARTIAL_UPLOAD: 'application/partial-upload', +} 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, + 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, 500], + getTusUploadOffset: [200], + patchTusUpload: [204, 500], + terminateTusUpload: [204, 423], + 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', + }, + 'ietf-draft-03': { + 'Upload-Draft-Interop-Version': '5', + }, + 'ietf-draft-05': { + 'Upload-Draft-Interop-Version': '6', + }, +} + +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', +} + +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 = { + extensionName: 'concatenation', + finalPrefix: 'final;', + headerName: 'Upload-Concat', + partialValue: 'partial', + uploadUrlSeparator: ' ', +} + +export const TUS_METADATA_ENCODING = { + entrySeparator: ',', + keyValueSeparator: ' ', + valueEncoding: 'base64', +} + +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 const TUS_RETRY_POLICY = { + clientErrorStatusCategory: 400, + lockedStatusCode: 423, + retryableClientStatusCodes: [409, 423], + successStatusCategory: 200, +} + +export const TUS_UPLOAD_BODY = { + contentTypeHeaderName: 'Content-Type', +} + +export const TUS_FLOW_POLICY = { + abort: { + error: { + message: 'Request was aborted', + name: 'AbortError', + type: 'DOMException', + }, + removeStoredUrlAfterTermination: 'after-successful-termination', + sequence: [ + 'mark-aborted', + 'abort-parallel-uploads', + 'abort-current-request', + 'clear-retry-timer', + 'terminate-upload-if-requested', + ], + suppressErrorAfterAbort: true, + terminateUpload: 'when-requested-and-upload-url-known', + terminateUploadContext: 'detached-from-aborted-request', + }, + detailedErrors: { + causeStringTemplate: 'Error: {message}', + 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: { + 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: { + contexts: { + createUpload: 'createUpload', + parallelFinalUpload: 'parallelFinalUpload', + resumeUpload: 'resumeUpload', + }, + createUpload: 'after-url-known-before-storage', + parallelFinalUpload: 'not-emitted', + resumeUpload: 'after-url-known-before-storage', + }, + }, + fileSources: { + commonTypes: [ + 'File', + 'Blob', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'ArrayBufferView', + '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', + 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)'], + }, + 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: { + conformanceFixture: { + absolutePath: '/tmp/tus-contract-file.bin', + mtimeMs: 1700000000123, + }, + 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: { + 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', + progressThrottle: { + leading: true, + milliseconds: 100, + trailing: false, + }, + nodeStackUnsupportedBodyType: 'throw', + }, + 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', + 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', + 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', + 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', + 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: + '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', + parallelUploadsWithUploadDataDuringCreation: + 'tus: cannot use the `uploadDataDuringCreation` 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', + 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)', + 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', + 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 ', + }, + 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', + nestedParallelUploads: 'disabled', + urlStorage: 'parent-managed', + }, + parallelUploadSplit: { + strategy: 'contiguous-floor-size-last-remainder', + }, + requestHeaders: { + layers: ['operation', 'custom', 'request-id'], + requestIdSource: 'sdk-generated-uuid', + }, + requestLifecycle: { + hooks: { + afterResponse: 'after-successful-transport-response', + 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: { + restart: 'start-upload-after-delay', + source: 'sdk-platform-timer', + }, + }, + }, + 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', + fields: { + creationTime: 'creationTime', + metadata: 'metadata', + size: 'size', + uploadUrl: 'uploadUrl', + urlStorageKey: 'urlStorageKey', + }, + missingUrl: 'fail', + storedUrlKind: 'single-or-parallel-upload-url', + }, + removeOnSuccess: 'when-option-enabled', + separator: '::', + webStorage: { + malformedEntry: 'ignore', + probeKey: 'tusSupport', + unavailableDomExceptionNames: ['QuotaExceededError', 'SecurityError'], + }, + }, +} + +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 } + +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 type TusUploadStartValidationReason = + | 'missingInput' + | 'unsupportedProtocol' + | 'missingEndpointOrUploadUrl' + | 'retryDelaysNotArray' + | 'parallelUploadsWithUploadUrl' + | 'parallelUploadsWithUploadSize' + | 'parallelUploadsWithDeferredLength' + | 'parallelUploadsWithUploadDataDuringCreation' + | '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 + uploadDataDuringCreation: boolean + 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 TusResumeOffsetResponsePlan = + | { + action: 'continue' + length: number | null + offset: number + uploadLengthDeferred: boolean + } + | { + action: 'fail' + message: string + reason: 'invalidLength' | 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' + } + +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 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 } + +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 } + +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 } + +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 interface TusParallelPartialUploadOptionsPlan { + headers: Record + metadata: Record + parallelUploadBoundaries: null + parallelUploads: number + removeFingerprintOnSuccess: boolean + storeFingerprintForResuming: boolean + 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 } + +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 } + +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 = + | { 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 } + | { + action: 'fail' + message: string + reason: 'invalidOffset' | 'missingOffset' | 'unexpectedStatus' + } + +export type TusTerminateResponsePlan = + | { action: 'complete' } + | { action: 'fail'; message: string; reason: 'unexpectedStatus' } + +export type TusRetryAfterErrorPlan = + | { action: 'emitError'; retryAttempt: number } + | { action: 'evaluatePolicy'; retryAttempt: number } + | { + action: 'scheduleRetry' + delay: number + nextRetryAttempt: number + offsetBeforeRetry: number + remainingRetryDelays: number[] + retryAttempt: number + } + +export type TusRemovedResumeOptionWarningPlan = + | { message: string; shouldWarn: true } + | { shouldWarn: false } + +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 TusAbortErrorDescriptor { + message: string + name: string + type: 'DOMException' +} + +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 interface TusRequestLifecycleHookPlan { + afterResponseHook: boolean + beforeRequestHook: boolean +} + +export type TusUploadUrlAvailableHookContext = + | 'createUpload' + | 'parallelFinalUpload' + | 'resumeUpload' + +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' } + +export interface TusHttpStackProgressThrottle { + leading: boolean + milliseconds: number + 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)) { + message = message.split(`{${name}}`).join(String(value)) + } + + return message +} + +export function tusDetailedErrorMissingValue(): string { + return TUS_FLOW_POLICY.detailedErrors.missingValue +} + +export function tusDetailedErrorEmptyResponseBody(): string { + return TUS_FLOW_POLICY.detailedErrors.emptyResponseBody +} + +export function tusDetailedErrorMessage({ + baseMessage, + cause, + requestContext, +}: { + baseMessage: string + cause?: string + requestContext?: TusDetailedErrorRequestContext +}): string { + let message = baseMessage + + if (cause != null) { + message += tusFormatFlowMessage(TUS_FLOW_POLICY.detailedErrors.causedByTemplate, { cause }) + } + + if (requestContext != null) { + message += tusFormatFlowMessage(TUS_FLOW_POLICY.detailedErrors.requestContextTemplate, { + body: requestContext.body, + method: requestContext.method, + requestId: requestContext.requestId, + status: requestContext.status, + url: requestContext.url, + }) + } + + 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, + uploadDataDuringCreation, + uploadLengthDeferred, +}: TusUploadStartValidationInput): TusUploadStartValidationResult { + if (hasFile === false) { + return tusUploadStartValidationError('missingInput', TUS_FLOW_POLICY.messages.missingInput) + } + + if (!tusSupportsProtocol(protocol)) { + return tusUploadStartValidationError( + 'unsupportedProtocol', + TUS_FLOW_POLICY.messages.unsupportedProtocolPrefix + protocol, + ) + } + + if (hasEndpoint === false && hasUploadUrl === false && hasCurrentUrl === false) { + 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 && hasUploadUrl === true) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadUrl', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadUrl, + ) + } + + if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && hasUploadSize === true) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadSize', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadSize, + ) + } + + if (parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && uploadLengthDeferred === true) { + return tusUploadStartValidationError( + 'parallelUploadsWithDeferredLength', + TUS_FLOW_POLICY.messages.parallelUploadsWithDeferredLength, + ) + } + + if ( + parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads && + uploadDataDuringCreation === true + ) { + return tusUploadStartValidationError( + 'parallelUploadsWithUploadDataDuringCreation', + TUS_FLOW_POLICY.messages.parallelUploadsWithUploadDataDuringCreation, + ) + } + + if ( + parallelUploadBoundariesCount != null && + parallelUploads < TUS_FLOW_POLICY.minimumParallelUploads + ) { + return tusUploadStartValidationError( + 'parallelBoundariesWithoutParallelUploads', + TUS_FLOW_POLICY.messages.parallelBoundariesWithoutParallelUploads, + ) + } + + if (parallelUploadBoundariesCount != null && parallelUploads !== parallelUploadBoundariesCount) { + return tusUploadStartValidationError( + 'parallelBoundariesLengthMismatch', + TUS_FLOW_POLICY.messages.parallelBoundariesLengthMismatch, + ) + } + + 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 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 tusNonErrorThrownValueMessage({ value }: { value: unknown }): string { + return tusFormatFlowMessage(TUS_FLOW_POLICY.messages.nonErrorThrownValue, { + value: String(value), + }) +} + +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 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 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 (addRequestId && !requestId) { + throw new Error('tus: request ID is required when addRequestId is enabled') + } + + return { + ...operationHeaders, + ...customHeaders, + ...(addRequestId && requestId ? tusRequestIdHeaders(requestId) : {}), + } +} + +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() +} + +export function tusResolveUploadLocation({ + location, + requestUrl, +}: { + location: string + requestUrl: 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 tusResolveRelativeUrl(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.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.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.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}`) + } +} + +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, +}: { + 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 tusShouldTreatRequestErrorAsRetryable({ + hasRequestContext, +}: { + hasRequestContext: boolean +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + return hasRequestContext +} + +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 +} + +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}`) + } + + if (policy.removeStoredUrlAfterTermination !== 'after-successful-termination') { + throw new Error( + `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 tusShouldSuppressErrorAfterAbort({ aborted }: { aborted: boolean }): boolean { + tusAssertAbortPolicySupported() + + return TUS_FLOW_POLICY.abort.suppressErrorAfterAbort && aborted +} + +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({ + hasCurrentRequest, + hasParallelUploads, + hasRetryTimer, + shouldTerminate, + uploadUrl, +}: { + hasCurrentRequest: boolean + hasParallelUploads: boolean + hasRetryTimer: boolean + shouldTerminate: boolean + uploadUrl: string | null +}): TusAbortPlan { + tusAssertAbortPolicySupported() + + const actions: TusAbortRuntimeAction[] = [] + for (const policyAction of TUS_FLOW_POLICY.abort.sequence) { + if (policyAction === 'mark-aborted') { + actions.push({ action: 'markAborted' }) + continue + } + + 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 { + 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', + } +} + +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: 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] +} + +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 tusCordovaInvalidArrayBufferResultMessage({ + resultType, +}: { + resultType: string +}): string { + return tusFormatFlowMessage( + TUS_FLOW_POLICY.fileSources.messages.cordovaInvalidArrayBufferResult, + { + resultType, + }, + ) +} + +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}`, + ) + } + + if (!Number.isFinite(policy.progressThrottle.milliseconds)) { + throw new Error( + `tus: unsupported HTTP progress throttle ${policy.progressThrottle.milliseconds}`, + ) + } +} + +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 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, +}: { + 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 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 tusPlanFingerprint({ + fingerprint, +}: { + fingerprint: string | null | undefined +}): TusFingerprintPlan { + if (!fingerprint) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.fingerprintUnavailable, + reason: 'missingFingerprint', + } + } + + 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, +}: { + hasValue: boolean +}): TusParallelUploadSlicePlan { + if (!hasValue) { + return { + ok: false, + message: TUS_FLOW_POLICY.messages.parallelUploadSliceMissingValue, + reason: 'missingValue', + } + } + + return { ok: true } +} + +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 tusPlanTerminateUploadRequest({ + uploadUrl, +}: { + uploadUrl: string +}): TusTerminateUploadRequestPlan { + return { + requestErrorMessage: TUS_FLOW_POLICY.messages.terminateUploadRequestFailed, + uploadUrl, + } +} + +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 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 + + 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}`) + } +} + +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 { + tusAssertEventHookPolicySupported() + tusAssertUrlStorageCleanupPolicySupported() + + 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 { + tusAssertUrlStorageRecordPolicySupported() + + return now.toString() +} + +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, + 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 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, + 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 tusPlanPreparedUploadMode({ + hasParallelUploadUrls, + parallelUploads, +}: { + hasParallelUploadUrls: boolean + parallelUploads: number +}): TusPreparedUploadModePlan { + if (hasParallelUploadUrls || parallelUploads >= TUS_FLOW_POLICY.minimumParallelUploads) { + return { action: 'parallel' } + } + + 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, + } +} + +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 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 +} + +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, +}: { + 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 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, +}: { + 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 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, +}: { + offset: number + offsetBeforeRetry: number +}): boolean { + tusAssertRequestLifecyclePolicySupported() + + 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({ + 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 } + } + + const nextRetryAttempt = tusNextRetryAttempt({ retryAttempt: effectiveRetryAttempt }) + return { + action: 'scheduleRetry', + delay: retryDelays[effectiveRetryAttempt], + nextRetryAttempt, + offsetBeforeRetry: offset, + remainingRetryDelays: [...retryDelays.slice(nextRetryAttempt)], + retryAttempt: effectiveRetryAttempt, + } +} + +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 +} + +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 tusPlanTerminateResponse({ status }: { status: number }): TusTerminateResponsePlan { + if (tusIsSuccessfulResponseStatus(status)) { + return { action: 'complete' } + } + + return { + action: 'fail', + message: TUS_FLOW_POLICY.messages.unexpectedTerminateResponse, + reason: 'unexpectedStatus', + } +} + +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 tusResponseHeadersForProtocol(protocol: string): Record { + return { ...(TUS_PROTOCOL_RESPONSE_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) +} + +export function tusUploadBodyContentTypeForProtocol(protocol: string): string | undefined { + return TUS_PROTOCOL_UPLOAD_BODY_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 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}${tusEncodeMetadataValue(String(value))}`, + ) + .join(TUS_METADATA_ENCODING.entrySeparator) +} + +export function tusMetadataHeaders(metadata: Record): Record { + const encodedMetadata = tusEncodeMetadata(metadata) + if (encodedMetadata === '') { + return {} + } + + return { + [TUS_HEADERS.UPLOAD_METADATA]: encodedMetadata, + } +} + +export function tusCreateUploadHeaders({ + metadata, + size, + uploadLengthDeferred, +}: { + 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), + } +} + +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({ + metadata, + uploadUrls, +}: { + metadata: Record + uploadUrls: readonly string[] +}): Record { + return { + [TUS_HEADERS.UPLOAD_CONCAT]: tusFinalUploadConcatValue(uploadUrls), + ...tusMetadataHeaders(metadata), + } +} + +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 = tusUploadBodyContentTypeForProtocol(protocol) + + return { + ...(contentType ? { [TUS_UPLOAD_BODY.contentTypeHeaderName]: contentType } : {}), + ...tusUploadCompleteHeaders({ done, protocol }), + } +} + +export function tusCreateUploadRequestPlan({ + endpoint, + metadata, + protocol, + size, + uploadComplete, + uploadLengthDeferred, +}: { + endpoint: string + metadata: Record + protocol: string + size: number | null + uploadComplete?: boolean + uploadLengthDeferred: boolean +}): TusRequestPlan { + return tusRequestPlanForOperation({ + headers: { + ...tusCreateUploadHeaders({ + metadata, + size, + uploadLengthDeferred, + }), + ...(uploadComplete == null + ? {} + : tusUploadCompleteHeaders({ done: uploadComplete, protocol })), + }, + operationId: TUS_OPERATION_IDS.CREATE_TUS_UPLOAD, + protocol, + url: endpoint, + }) +} + +export function tusFinalUploadRequestPlan({ + endpoint, + metadata, + protocol, + uploadUrls, +}: { + endpoint: string + metadata: Record + protocol: string + uploadUrls: readonly string[] +}): TusRequestPlan { + return tusRequestPlanForOperation({ + headers: tusFinalUploadHeaders({ + 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 operationId = TUS_OPERATION_IDS.PATCH_TUS_UPLOAD + const methodOverride = overridePatchMethod + ? tusMethodOverrideForOperation(operationId) + : undefined + const plan = tusRequestPlanForOperation({ + headers: { + ...(methodOverride?.headers ?? {}), + ...tusPatchUploadHeaders({ offset }), + }, + operationId, + 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 { + 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, + } +} + +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/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/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`. 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 3cebb34cc..0ca6ab3e6 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,29 +1,100 @@ -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' import { DetailedError } from './DetailedError.js' import { log } from './logger.js' -import { - type FileSource, - type HttpRequest, - type HttpResponse, - PROTOCOL_IETF_DRAFT_03, - PROTOCOL_IETF_DRAFT_05, - PROTOCOL_TUS_V1, - type PreviousUpload, - type SliceType, - type UploadInput, - type UploadOptions, +import type { + FileSource, + HttpRequest, + HttpResponse, + PreviousUpload, + SliceType, + UploadInput, + UploadOptions, } from './options.js' +import { + type TusChunkCompleteEventPlanInput, + type TusProgressEventPlanInput, + type TusRequestPlan, + type TusUploadUrlAvailableHookContext, + tusCheckConfiguredUploadSize, + tusChunkEnd, + tusCreateUploadRequestPlan, + tusDefaultClientOptions, + tusDefaultRetryOnlineStatus, + tusDefaultRetryPolicyDecision, + tusDeferredUploadLengthPlan, + tusFinalUploadRequestPlan, + tusGetUploadOffsetRequestPlan, + tusNonErrorThrownValueMessage, + tusPatchUploadRequestPlan, + tusPlanAbort, + tusPlanChunkCompleteEvent, + tusPlanCreatedUploadLog, + tusPlanFinalUploadCreation, + tusPlanFingerprint, + tusPlanParallelPartialUploadOptions, + tusPlanParallelUploadParts, + tusPlanParallelUploadSlice, + tusPlanPreparedFingerprintLog, + tusPlanPreparedUploadMode, + tusPlanPreparedUploadSize, + tusPlanProgressEvent, + tusPlanRemovedResumeOptionWarning, + tusPlanRequestHeaders, + tusPlanRequestId, + tusPlanRequestLifecycleHooks, + tusPlanResumeOffsetResponse, + tusPlanResumeResponseStatus, + tusPlanResumeUploadRequest, + tusPlanSingleUploadStart, + tusPlanStoredUploadRecord, + tusPlanSuccessEvent, + tusPlanTerminateResponse, + tusPlanTerminateUploadRequest, + tusPlanUploadChunkRequest, + tusPlanUploadChunkResponse, + tusPlanUploadCreationRequest, + tusPlanUploadCreationResponse, + tusPlanUploadStorage, + tusPlanUploadUrlAvailableHook, + tusReadUploadChunkResponse, + tusReadUploadCreationResponse, + tusReadUploadOffsetResponse, + tusResolveUploadLocation, + tusShouldSendUploadBodyDuringCreation, + tusShouldSuppressErrorAfterAbort, + tusShouldUseCustomRetryPolicy, + tusTerminateUploadRequestPlan, + tusUploadBodyHeaders, + tusUploadLengthHeaders, + 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 { + 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: +// the response carries the accepted offset, the request provides the error context. +interface TusChunkExchange { + req: HttpRequest + 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, uploadUrl: undefined, - metadata: {}, - metadataForPartialUploads: {}, fingerprint: undefined, uploadSize: undefined, @@ -33,27 +104,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: PROTOCOL_TUS_V1 as UploadOptions['protocol'], + ...tusDefaultClientOptions(), } export class BaseUpload { @@ -110,11 +171,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. @@ -131,11 +192,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 { @@ -145,81 +207,29 @@ export class BaseUpload { } start(): void { - if (!this.file) { - this._emitError(new Error('tus: no file or stream to upload provided')) - return - } - - if ( - ![PROTOCOL_TUS_V1, PROTOCOL_IETF_DRAFT_03, PROTOCOL_IETF_DRAFT_05].includes( - 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, + uploadDataDuringCreation: this.options.uploadDataDuringCreation, + 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) => { 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 @@ -228,46 +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) - 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}`) - } - - if (this._source == null) { - this._source = await this.options.fileReader.openFile(this.file, this.options.chunkSize) - } + 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) + } - // 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", - ) - } - } + 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) + + if (plan.action === 'resumeConfigured') { + this.url = plan.url + } - // 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) { - await this._startParallelUpload() - } else { - await this._startSingleUpload() - } + return plan.action !== 'create' + }, + resumeUpload: () => this._resumeUpload(), + shouldUploadInParallel: () => + tusPlanPreparedUploadMode({ + hasParallelUploadUrls: this._parallelUploadUrls != null, + parallelUploads: this.options.parallelUploads, + }).action === 'parallel', + startParallelUpload: () => this._startParallelUpload(), + }) } /** @@ -277,29 +303,20 @@ 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 totalAccepted = 0 + let totalProgress = 0 + this._parallelUploads = [] // Create an empty list for storing the upload URLs this._parallelUploadUrls = new Array(parts.length) @@ -307,31 +324,22 @@ 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. 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, - 'Upload-Concat': 'partial', - }, + ...partialUploadOptions, // Reject or resolve the promise if the upload errors or completes. onSuccess: resolve, onError: reject, @@ -340,10 +348,23 @@ 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) + this._emitProgress({ + bytesTotal: totalSize, + hasHook: typeof this.options.onProgress === 'function', + phase: 'parallelPartProgress', + 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. @@ -358,8 +379,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 } @@ -377,78 +399,54 @@ 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 req = this._openRequest('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) - } + const finalUploadCreationPlan = tusPlanFinalUploadCreation({ + endpoint: this.options.endpoint, + partialUploadUrls: this._parallelUploadUrls, + }) + if (!finalUploadCreationPlan.ok) { + throw new Error(finalUploadCreationPlan.message) + } + const req = this._openRequest( + tusFinalUploadRequestPlan({ + endpoint: finalUploadCreationPlan.endpoint, + metadata: this.options.metadata, + protocol: this.options.protocol, + uploadUrls: finalUploadCreationPlan.uploadUrls, + }), + ) let res: HttpResponse try { 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('tus: failed to concatenate parallel uploads', err, req, undefined) - } - - if (!inStatusCategory(res.getStatus(), 200)) { - throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) - } - - const location = res.getHeader('Location') - if (location == null) { - throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) + throw new DetailedError(finalUploadCreationPlan.requestErrorMessage, err, req, undefined) } - if (this.options.endpoint == null) { - throw new Error('tus: Expeced endpoint to be defined.') + const creationResponsePlan = tusPlanUploadCreationResponse({ + followUp: 'none', + response: tusReadUploadCreationResponse({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }), + size: this._size, + }) + if (creationResponsePlan.action === 'fail') { + throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - this.url = resolveUrl(this.options.endpoint, location) - log(`Created upload at ${this.url}`) + this.url = tusResolveUploadLocation({ + location: creationResponsePlan.location, + requestUrl: finalUploadCreationPlan.endpoint, + }) + log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) 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 - - // The upload had been started previously and we should reuse this URL. - if (this.url != null) { - log(`Resuming upload from previous URL: ${this.url}`) - 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 - 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() - } - /** * 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 @@ -460,38 +458,56 @@ 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 + } - 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() + 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() + } + } } } 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) @@ -500,34 +516,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 - - // 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 - const shouldResetDelays = this._offset != null && this._offset > this._offsetBeforeRetry - if (shouldResetDelays) { - this._retryAttempt = 0 - } - - if (shouldRetry(err, this._retryAttempt, this.options)) { - const delay = this.options.retryDelays[this._retryAttempt++] - - this._offsetBeforeRetry = this._offset - + 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, + scheduleRestart: (delayMs) => { this._retryTimeout = setTimeout(() => { this.start() - }, delay) - return - } - } - - // If we are not retrying, emit the error to the user. - this._emitError(err) + }, delayMs) + }, + setOffsetBeforeRetry: (offset) => { + this._offsetBeforeRetry = offset + }, + setRetryAttempt: (retryAttempt) => { + this._retryAttempt = retryAttempt + }, + }) } /** @@ -537,15 +556,25 @@ export class BaseUpload { * @api private */ private async _emitSuccess(lastResponse: HttpResponse): Promise { - if (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() + } } /** @@ -556,9 +585,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) } } @@ -571,258 +601,357 @@ 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, + ) + } + } + + 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 - * 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 { - if (!this.options.endpoint) { - throw new Error('tus: unable to create upload because no endpoint is provided') - } - - const req = this._openRequest('POST', this.options.endpoint) - - if (this._uploadLengthDeferred) { - req.setHeader('Upload-Defer-Length', '1') - } else { - if (this._size == null) { - throw new Error('tus: expected _size to be set') - } - req.setHeader('Upload-Length', `${this._size}`) - } + 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, + }) + } - // Add metadata if values have been added - const metadata = encodeMetadata(this.options.metadata) - if (metadata !== '') { - req.setHeader('Upload-Metadata', metadata) - } + /** + * 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, + uploadDataDuringCreation: this.options.uploadDataDuringCreation, + uploadLengthDeferred: this._uploadLengthDeferred, + }) + if (!creationRequestPlan.ok) { + throw new Error(creationRequestPlan.message) + } + + const req = this._openRequest( + tusCreateUploadRequestPlan({ + endpoint: creationRequestPlan.endpoint, + metadata: this.options.metadata, + protocol: this.options.protocol, + size: this._size, + uploadComplete: creationRequestPlan.uploadComplete, + 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 { - if ( - this.options.protocol === PROTOCOL_IETF_DRAFT_03 || - this.options.protocol === PROTOCOL_IETF_DRAFT_05 - ) { - req.setHeader('Upload-Complete', '?0') - } 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('tus: failed to create upload', err, req, undefined) - } - - if (!inStatusCategory(res.getStatus(), 200)) { - throw new DetailedError('tus: unexpected response while creating upload', undefined, req, res) + throw new DetailedError(creationRequestPlan.requestErrorMessage, err, req, undefined) } - const location = res.getHeader('Location') - if (location == null) { - throw new DetailedError('tus: invalid or missing Location header', undefined, req, res) - } - - if (this.options.endpoint == null) { - throw new Error('tus: Expected options.endpoint to be set') - } - - this.url = resolveUrl(this.options.endpoint, location) - log(`Created upload at ${this.url}`) - - if (typeof this.options.onUploadUrlAvailable === 'function') { - await this.options.onUploadUrlAvailable() - } + return { endpoint: creationRequestPlan.endpoint, req, res } + } - if (this._size === 0) { - // Nothing to upload and file was successfully created - await this._emitSuccess(res) - if (this._source) this._source.close() - return + /** + * 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({ + getHeader: (headerName) => res.getHeader(headerName), + status: res.getStatus(), + }), + size: this._size, + }) + if (creationResponsePlan.action === 'fail') { + throw new DetailedError(creationResponsePlan.message, undefined, req, res) } - await this._saveUploadInUrlStorage() + this.url = tusResolveUploadLocation({ + location: creationResponsePlan.location, + requestUrl: endpoint, + }) + log(tusPlanCreatedUploadLog({ uploadUrl: this.url }).message) - 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 { - if (this.url == null) { - throw new Error('tus: Expected url to be set') + 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('HEAD', this.url) - let res: HttpResponse + const req = this._openRequest( + tusGetUploadOffsetRequestPlan({ + protocol: this.options.protocol, + uploadUrl: resumeRequestPlan.uploadUrl, + }), + ) + try { - res = await this._sendRequest(req) + return { req, 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('tus: failed to resume upload', err, req, undefined) - } - - const status = res.getStatus() - if (!inStatusCategory(status, 200)) { - // If the upload is locked (indicated by the 423 Locked status code), 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) { - throw new DetailedError('tus: upload is currently locked; retry later', undefined, req, res) - } - - if (inStatusCategory(status, 400)) { - // Remove stored fingerprint and corresponding endpoint, - // on client errors since the file can not be found - 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, - ) - } - - // Try to create a new upload - this.url = null - await this._createUpload() + throw new DetailedError(resumeRequestPlan.requestErrorMessage, err, req, undefined) } + } - const offsetStr = res.getHeader('Upload-Offset') - if (offsetStr === undefined) { - throw new DetailedError('tus: missing Upload-Offset header', undefined, req, res) - } - const offset = Number.parseInt(offsetStr, 10) - if (Number.isNaN(offset)) { - throw new DetailedError('tus: invalid Upload-Offset header', undefined, req, res) + /** + * 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: res.getStatus(), + }) + if (responseStatusPlan.action === 'readOffset') { + return false } - const deferLength = res.getHeader('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) - if ( - Number.isNaN(length) && - !this._uploadLengthDeferred && - this.options.protocol === PROTOCOL_TUS_V1 - ) { - throw new DetailedError('tus: invalid or missing length value', undefined, req, res) + if (responseStatusPlan.removeStoredUpload) { + await this._removeFromUrlStorage() } - if (typeof this.options.onUploadUrlAvailable === 'function') { - await this.options.onUploadUrlAvailable() + if (responseStatusPlan.action === 'fail') { + throw new DetailedError(responseStatusPlan.message, undefined, req, res) } - await this._saveUploadInUrlStorage() + return true + } - // Upload has already been completed and we do not need to send additional - // data to the server - if (offset === length) { - this._emitProgress(length, length) - await this._emitSuccess(res) - return + /** + * 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: 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) } - this._offset = offset - await this._performUpload() + this._uploadLengthDeferred = offsetResponsePlan.uploadLengthDeferred + 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 { - // 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 - } + private _readResumeUploadLength({ res }: TusChunkExchange): number | null { + const offsetResponse = tusReadUploadOffsetResponse({ + getHeader: (headerName) => res.getHeader(headerName), + protocol: this.options.protocol, + status: res.getStatus(), + }) - let req: HttpRequest + return offsetResponse.ok ? offsetResponse.length : null + } - 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 - // 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') - } else { - req = this._openRequest('PATCH', this.url) - } + /** + * 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(), + }) + } - req.setHeader('Upload-Offset', `${this._offset}`) + /** + * 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, + }) + if (!chunkRequestPlan.ok) { + throw new Error(chunkRequestPlan.message) + } + const req = this._openRequest( + tusPatchUploadRequestPlan({ + offset: this._offset, + overridePatchMethod: this.options.overridePatchMethod, + protocol: this.options.protocol, + uploadUrl: chunkRequestPlan.uploadUrl, + }), + ) - 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(`tus: value thrown that is not an error: ${err}`) + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } - throw new DetailedError( - `tus: failed to upload chunk at offset ${this._offset}`, - err, - req, - undefined, - ) + throw new DetailedError(chunkRequestPlan.requestErrorMessage, err, req, undefined) } + } - if (!inStatusCategory(res.getStatus(), 200)) { - throw new DetailedError('tus: unexpected response while uploading chunk', undefined, 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) } - await this._handleUploadResponse(req, res) + this._offset = chunkResponsePlan.offset + return chunkResponsePlan.chunkSize } /** @@ -833,105 +962,78 @@ 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) + this._emitProgress({ + bytesTotal: this._size, + hasHook: typeof this.options.onProgress === 'function', + phase: 'duringRequest', + startOffset: start, + transmittedBytes: bytesSent, + }) }) - if (this.options.protocol === PROTOCOL_TUS_V1) { - req.setHeader('Content-Type', 'application/offset+octet-stream') - } else if (this.options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader('Content-Type', 'application/partial-upload') - } - - // 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 - req.setHeader('Upload-Length', `${this._size}`) + 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 } + 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 // 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) { return await this._sendRequest(req) } - if ( - this.options.protocol === PROTOCOL_IETF_DRAFT_03 || - this.options.protocol === PROTOCOL_IETF_DRAFT_05 - ) { - req.setHeader('Upload-Complete', done ? '?1' : '?0') - } - 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) } - /** - * _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 { - // TODO: || '' is not very good. - const offset = Number.parseInt(res.getHeader('Upload-Offset') || '', 10) - if (Number.isNaN(offset)) { - throw new DetailedError('tus: invalid or missing offset value', undefined, req, res) - } - - this._emitProgress(offset, this._size) - this._emitChunkComplete(offset - this._offset, offset, this._size) - - this._offset = offset - - if (offset === this._size) { - // Yay, finally done :) - await this._emitSuccess(res) - if (this._source) this._source.close() - return - } - - await this._performUpload() - } - /** * Create a new HTTP request object with the given method and URL. * * @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 } @@ -954,35 +1056,37 @@ 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 } - const storedUpload: PreviousUpload = { - size: this._size, + const recordPlan = tusPlanStoredUploadRecord({ + creationTime: tusUrlStorageCreationTime({ now: new Date() }), + fingerprint: storagePlan.fingerprint, metadata: this.options.metadata, - creationTime: new Date().toString(), - urlStorageKey: this._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 + 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(this._fingerprint, storedUpload) + const urlStorageKey = await this.options.urlStorage.addUpload( + storagePlan.fingerprint, + recordPlan.upload, + ) // TODO: Emit a waring if urlStorageKey is undefined. Should we even allow this? this._urlStorageKey = urlStorageKey } @@ -997,20 +1101,10 @@ export class BaseUpload { } } -function encodeMetadata(metadata: Record): string { - return Object.entries(metadata) - .map(([key, value]) => `${key} ${Base64.encode(String(value))}`) - .join(',') -} - -/** - * 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) + } } /** @@ -1020,26 +1114,22 @@ 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) - - if (options.protocol === PROTOCOL_IETF_DRAFT_03) { - req.setHeader('Upload-Draft-Interop-Version', '5') - } else if (options.protocol === PROTOCOL_IETF_DRAFT_05) { - req.setHeader('Upload-Draft-Interop-Version', '6') - } else { - req.setHeader('Tus-Resumable', '1.0.0') - } - const headers = options.headers || {} - - for (const [name, value] of Object.entries(headers)) { - req.setHeader(name, value) - } +function openRequest(plan: TusRequestPlan, options: UploadOptions): HttpRequest { + const req = options.httpStack.createRequest(plan.method, plan.url) + const requestId = tusPlanRequestId({ + addRequestId: options.addRequestId, + generateRequestId: uuid, + }) - if (options.addRequestId) { - const requestId = uuid() - req.setHeader('X-Request-ID', requestId) - } + setRequestHeaders( + req, + tusPlanRequestHeaders({ + addRequestId: options.addRequestId, + customHeaders: options.headers || {}, + operationHeaders: plan.headers, + requestId, + }), + ) return req } @@ -1055,13 +1145,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) } @@ -1076,47 +1171,24 @@ 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 }) } -/** - * 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 - */ -function shouldRetry( - err: Error | DetailedError, +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 is server error (i.e. not a status 4xx except a 409 or 423) 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 + tusShouldUseCustomRetryPolicy({ + hasCustomRetryPolicy: typeof options.onShouldRetry === 'function', + }) && + typeof options.onShouldRetry === 'function' ) { - return false - } - - if (options && typeof options.onShouldRetry === 'function') { return options.onShouldRetry(err, retryAttempt, options) } @@ -1124,50 +1196,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() -} - -/** - * 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 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 + return tusDefaultRetryPolicyDecision({ isOnline: isOnline(), status }) } function wait(delay: number) { @@ -1177,56 +1212,55 @@ 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 { - const req = openRequest('DELETE', url, options) +async function sendTerminateRequest(url: string, options: UploadOptions): Promise { + const terminateRequestPlan = tusPlanTerminateUploadRequest({ uploadUrl: url }) + const plan = tusTerminateUploadRequestPlan({ + protocol: options.protocol, + uploadUrl: terminateRequestPlan.uploadUrl, + }) + const req = openRequest(plan, options) try { const res = await sendRequest(req, undefined, options) - // A 204 response indicates a successfull request - if (res.getStatus() === 204) { + const responsePlan = tusPlanTerminateResponse({ status: res.getStatus() }) + if (responsePlan.action === 'complete') { return } - throw new DetailedError( - 'tus: unexpected response while terminating upload', - undefined, - req, - res, - ) + throw new DetailedError(responsePlan.message, undefined, req, res) } catch (err) { if (!(err instanceof Error)) { - throw new Error(`tus: value thrown that is not an error: ${err}`) - } - - const detailedErr = - err instanceof DetailedError - ? err - : new DetailedError('tus: failed to terminate upload', err, req) - - if (!shouldRetry(detailedErr, 0, options)) { - throw detailedErr + throw new Error(tusNonErrorThrownValueMessage({ value: err })) } - // Instead of keeping track of the retry attempts, we remove the first element from the delays - // array. If the array is empty, all retry attempts are used up and we will bubble up the error. - // We recursively call the terminate function will removing elements from the retryDelays array. - const delay = options.retryDelays[0] - const remainingDelays = options.retryDelays.slice(1) - const newOptions = { - ...options, - retryDelays: remainingDelays, + if (err instanceof DetailedError) { + throw err } - await wait(delay) - await terminate(url, newOptions) + throw new DetailedError(terminateRequestPlan.requestErrorMessage, err, req) } } + +/** + * 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. + * + * @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. + */ +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, + }) +} 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 + } + } +} 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) +} diff --git a/test/karma/puppeteer.conf.cjs b/test/karma/puppeteer.conf.cjs index 3855e7d91..2222cc99f 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) @@ -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, }) } 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..8c7fa1a3e --- /dev/null +++ b/test/spec/generated-protocol-contract.js @@ -0,0 +1,7468 @@ +// 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 tusWireVersions = [ + { + default: true, + value: '1.0.0', + }, +] + +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, + }, + ], + }, + { + 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: [ + { + statusCode: 201, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Location', + name: 'location', + required: true, + }, + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + required: true, + }, + ], + }, + ], + }, + { + statusCode: 500, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + 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, + }, + ], + }, + ], + }, + { + statusCode: 500, + bodyKind: 'empty', + headerVariants: [ + { + fields: [ + { + displayName: 'Tus-Resumable', + name: 'tus-resumable', + 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, + }, + ], + }, + ], + }, + { + statusCode: 423, + 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 = [ + { + 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', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + }, + { + 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', 'deferredLengthChunkedUpload'], + status: 'covered-by-generated-scenario', + }, + description: + 'Create an upload without a known length and declare the length on the final upload request.', + 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 upload request reveals the total size.', + }, + { + kind: 'operation', + operationId: 'patchTusUpload', + summary: 'Declare Upload-Length on the final upload request.', + }, + ], + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-chunk-complete', 'emit-progress'], + }, + { + conformance: { + scenarioIds: ['creationWithUpload', 'creationWithUploadPartialChunk'], + 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', 'patchTusUpload'], + 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: ['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: ['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'], + 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', 'parallelUploadAbortCleanup'], + status: 'covered-by-generated-scenario', + }, + description: + 'Split one input into partial uploads, run the parts concurrently, clean up aborted parts, 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: [ + 'abort-current-request', + 'concatenate-partial-uploads', + 'emit-progress', + 'split-parallel-upload-boundaries', + 'terminate-upload', + ], + }, + { + 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: ['retryPatchAfterOffsetRecovery'], + status: 'covered-by-generated-scenario', + }, + description: 'Schedule retry timers and reset retry attempts after accepted progress.', + featureId: 'retryStateTransitions', + flow: [ + { + kind: 'primitive', + primitive: 'schedule-retry-timer', + summary: 'Consume the current retry delay and restart the upload after that timer fires.', + }, + { + kind: 'primitive', + primitive: 'reset-retry-attempt-after-progress', + summary: 'Reset retry attempts once a later retry observes server-side offset progress.', + }, + ], + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: [ + 'retry-with-backoff', + 'schedule-retry-timer', + 'reset-retry-attempt-after-progress', + ], + }, + { + 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: ['abortUpload', 'abortUploadAfterStoredUrl'], + status: 'covered-by-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: ['terminateTusUpload'], + primitives: ['abort-current-request', 'terminate-upload'], + }, + { + conformance: { + scenarioIds: ['singleUploadLifecycle', 'creationWithUpload', 'resumeFromPreviousUpload'], + status: 'covered-by-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: ['requestLifecycleHooks', 'retryPatchAfterOffsetRecovery'], + status: 'covered-by-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: ['singleUploadLifecycle', 'resumeFromPreviousUpload'], + status: 'covered-by-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: [ + 'arrayBufferInput', + 'arrayBufferViewInput', + 'webReadableStreamInput', + 'nodeReadableStreamInput', + 'nodePathInput', + ], + status: 'covered-by-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: ['webStorageUrlStorageBackend', 'fileUrlStorageBackend'], + status: 'covered-by-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: [ + 'ietfDraft05CreationWithUpload', + 'ietfDraft05ChunkedUploadComplete', + 'ietfDraft03ResumeWithoutKnownLength', + ], + status: 'covered-by-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: ['relativeLocationResolution'], + status: 'covered-by-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: [ + 'startValidationMissingInput', + 'startValidationMissingEndpointOrUploadUrl', + 'startValidationUnsupportedProtocol', + 'startValidationRetryDelaysNotArray', + 'startValidationParallelUploadsWithUploadUrl', + 'startValidationParallelUploadsWithUploadSize', + 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelUploadsWithUploadDataDuringCreation', + 'startValidationParallelBoundariesWithoutParallelUploads', + 'startValidationParallelBoundariesLengthMismatch', + ], + status: 'covered-by-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: ['detailedCreateResponseError', 'detailedCreateRequestError'], + status: 'covered-by-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 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: { + policies: [ + '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', + ], + }, + 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', + 'managedUploadRetryPolicyExhausted', + 'managedUploadSourceUnavailable', + 'managedUploadNetworkConstraint', + ], + 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.', + 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', + transportProfileId: 'java-http-url-connection', + }, + { + 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', + transportProfileId: 'java-http-url-connection', + }, + { + 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: [ + { + proofs: [ + { + attempts: [ + { + attemptIndex: 0, + failure: { + afterAcceptedOffset: 7, + kind: 'io-error', + phase: 'after-accepted-offset', + }, + 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', + }, + 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'], + runtime: 'java', + scheduler: 'process-lifetime-worker-pool', + stateBackend: 'filesystem', + }, + { + attempts: [ + { + attemptIndex: 0, + failure: { + afterAcceptedOffset: 7, + kind: 'io-error', + phase: 'after-accepted-offset', + }, + 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', + }, + 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'], + 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', + '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.', + }, + { + 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', + }, + 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'], + 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', + }, + 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'], + 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: 'Classify 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', + }, + 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'], + 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', + }, + 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'], + 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.', + }, + { + 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', + }, + 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'], + 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', + }, + 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'], + 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.', + }, + { + 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', + ], + scenarioId: 'managedUploadNetworkConstraint', + summary: 'Honor network constraints before starting or resuming upload work.', + }, + ], +} + +export const tusManagedUploadProofCases = [ + { + 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', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadDurableRetry', + }, + { + 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', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 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', + 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', + proofRuntimes: ['android'], + protocolFeatureIds: ['singleUploadLifecycle', 'retryOffsetRecovery'], + requiredPrimitives: [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'publish-upload-state', + ], + runtimeProfiles: ['android', 'ios', 'browser', 'java', 'node', 'react-native'], + scenarioId: 'managedUploadNetworkConstraint', + }, +] + +export const tusClientConformanceScenarios = [ + { + behavior: 'single-upload-lifecycle', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/generated-contract', + 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', + ], + eventKinds: [ + 'fingerprint', + 'upload-url-available', + 'url-storage-add', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'singleUploadLifecycle', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/generated-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/generated-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: 'contract-single-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: true, + storedUpload: null, + }, + }, + scenarioId: 'singleUploadLifecycle', + }, + { + behavior: 'creation-with-upload', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/creation-with-upload-contract', + eventKeyAlternativeGroups: [[], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], + eventKinds: ['progress', 'upload-url-available', 'success', 'source-close'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'creationWithUpload', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'uploadDataDuringCreation', + value: true, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['createTusUpload'], + primitives: ['upload-during-creation', 'emit-progress'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + 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, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'creationWithUpload', + }, + { + behavior: 'creation-with-upload-partial-chunk', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + 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', + ], + eventKinds: ['progress', 'upload-url-available', 'chunk-complete', 'success', 'source-close'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'creationWithUpload', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['upload-during-creation', 'emit-progress'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + 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, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 1, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/creation-with-upload-partial-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'creationWithUploadPartialChunk', + }, + { + behavior: 'creation-with-upload', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/ietf-draft-05-contract', + eventKeyAlternativeGroups: [[], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], + eventKinds: ['progress', 'upload-url-available', 'success', 'source-close'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'protocolVersionSelection', + 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', + }, + operationIds: ['createTusUpload'], + primitives: ['select-client-protocol'], + requests: [ + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: 11, + bodyStart: null, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Length': '11', + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + 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, + 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', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'ietfDraft05CreationWithUpload', + }, + { + behavior: 'upload-body-headers', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + 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', + ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'protocolVersionSelection', + 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', + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['select-client-protocol'], + requests: [ + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: 'exact', + headers: {}, + headersSpecified: false, + method: null, + operationId: 'getTusUploadOffset', + response: { + body: null, + headerMode: 'exact', + headers: { + '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', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: 5, + bodyStart: null, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', + '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', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: 5, + bodyStart: null, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', + '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', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: 1, + bodyStart: null, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '10', + }, + headersSpecified: true, + method: null, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: 'exact', + headers: { + 'Upload-Offset': '11', + }, + headersSpecified: true, + statusCode: 204, + effectiveHeaders: { + 'Upload-Offset': '11', + }, + }, + role: 'upload-final-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 3, + effectiveHeaders: { + 'Upload-Draft-Interop-Version': '6', + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '10', + }, + effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'ietfDraft05ChunkedUploadComplete', + }, + { + behavior: 'upload-body-headers', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + eventKeyAlternativeGroups: [[], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'success', + 'source-close', + ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'protocolVersionSelection', + 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', + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['select-client-protocol'], + requests: [ + { + absentHeaders: ['Tus-Resumable'], + abort: false, + bodySize: null, + bodyStart: 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', + expectedUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + { + absentHeaders: ['Content-Type', 'Tus-Resumable'], + abort: false, + bodySize: 6, + bodyStart: null, + errorMessage: null, + headerMode: 'exact', + headers: { + 'Upload-Complete': '?1', + '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', + expectedUrl: 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'ietfDraft03ResumeWithoutKnownLength', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: 'tus: no file or stream to upload provided', + completionReason: 'missingInput', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + ], + inputSource: { + content: '', + kind: 'none', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: 'tus: neither an endpoint or an upload URL is provided', + completionReason: 'missingEndpointOrUploadUrl', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: 'tus: unsupported protocol tus-v9', + completionReason: 'unsupportedProtocol', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'protocol', + value: 'tus-v9', + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: 'tus: the `retryDelays` option must either be an array or null', + completionReason: 'retryDelaysNotArray', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'rawOptions', + value: { + retryDelays: 44, + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithUploadUrl', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + 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', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithUploadSize', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadSize', + value: 11, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: + 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithDeferredLength', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadLengthDeferred', + value: true, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: + 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + completionReason: 'parallelUploadsWithUploadDataDuringCreation', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploads', + value: 2, + }, + { + key: 'uploadDataDuringCreation', + value: true, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: + 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + completionReason: 'parallelBoundariesWithoutParallelUploads', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'parallelUploadBoundaries', + value: [ + { + end: 5, + start: 0, + }, + ], + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [], + 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', + }, + { + behavior: 'start-option-validation', + completionKind: 'error', + completionMessage: + 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + completionReason: 'parallelBoundariesLengthMismatch', + completionUploadUrl: null, + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'startOptionValidation', + 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', + }, + operationIds: [], + 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', + }, + { + behavior: 'detailed-error', + 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: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'detailedErrors', + 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', + }, + operationIds: ['createTusUpload'], + primitives: ['report-detailed-errors'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': 'contract-request-id', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: 'server_error', + headerMode: null, + headers: {}, + headersSpecified: false, + statusCode: 500, + effectiveHeaders: { + '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', + 'X-Request-ID': 'contract-request-id', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'detailedCreateResponseError', + }, + { + behavior: 'detailed-error', + 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: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'detailedErrors', + 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', + }, + operationIds: ['createTusUpload'], + primitives: ['report-detailed-errors'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: 'socket down', + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + 'X-Request-ID': 'contract-request-id', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: null, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'detailedCreateRequestError', + }, + { + behavior: 'upload-body-headers', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/upload-body-headers-contract', + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'uploadBodyHeaders', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['send-upload-body-headers'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + 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, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/upload-body-headers-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'uploadBodyHeaders', + }, + { + behavior: 'custom-request-headers', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/custom-headers-contract', + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'customRequestHeaders', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['apply-custom-request-headers'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + 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: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/custom-headers-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/custom-headers-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: 'contract-custom-headers-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'customRequestHeaders', + }, + { + behavior: 'request-id-headers', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/request-id-contract', + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'requestIdHeaders', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['add-request-id-header', 'apply-custom-request-headers'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + '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, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/request-id-contract', + }, + ], + 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', + }, + { + behavior: 'resume-from-previous-upload', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/resume-contract', + 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', + ], + 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', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [ + { + actions: [ + { + expectedPreviousUploadCount: 1, + kind: 'resume-from-previous-upload', + selectedPreviousUploadIndex: 0, + }, + ], + phase: 'beforeStart', + }, + ], + featureId: 'resumeUpload', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'removeFingerprintOnSuccess', + value: true, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['fingerprint-input', 'resume-from-previous-upload', 'store-resume-url'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/resume-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 6, + bodyStart: null, + 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': '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', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/resume-contract', + }, + ], + 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', + }, + { + behavior: 'relative-location-resolution', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/files/relative-contract', + eventKeyAlternativeGroups: [[], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'relativeLocationResolution', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/files/', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['resolve-relative-location'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'relative-contract', + }, + 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', + expectedUrl: 'https://tus.io/files/', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/files/relative-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'relativeLocationResolution', + }, + { + behavior: 'array-buffer-input', + 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'], + eventKinds: ['source-open', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'inputSources', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'array-buffer', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-browser-file'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/array-buffer-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/array-buffer-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'arrayBufferInput', + }, + { + behavior: 'array-buffer-view-input', + 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'], + eventKinds: ['source-open', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'inputSources', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'array-buffer-view', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-browser-file'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + 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, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/array-buffer-view-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'arrayBufferViewInput', + }, + { + behavior: 'web-readable-stream-input', + 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'], + eventKinds: ['source-open', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'inputSources', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-web-stream'], + requests: [ + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/web-stream-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + '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, + 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', + expectedUrl: 'https://tus.io/uploads/web-stream-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'webReadableStreamInput', + }, + { + behavior: 'node-readable-stream-input', + 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'], + eventKinds: ['source-open', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'inputSources', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-node-stream'], + requests: [ + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/node-stream-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + '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, + 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', + expectedUrl: 'https://tus.io/uploads/node-stream-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'nodeReadableStreamInput', + runtimes: ['node'], + }, + { + behavior: 'node-path-input', + 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'], + eventKinds: ['source-open', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'inputSources', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'node-path-reference', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['read-node-file'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/node-path-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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, + 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', + expectedUrl: 'https://tus.io/uploads/node-path-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'nodePathInput', + runtimes: ['node'], + }, + { + behavior: 'deferred-length-upload', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/deferred-contract', + eventKeyAlternativeGroups: [[], [], [], [], [], []], + eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + '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', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'deferredLengthUpload', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-progress'], + requests: [ + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + '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, + 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', + expectedUrl: 'https://tus.io/uploads/deferred-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'deferredLengthUpload', + }, + { + behavior: 'deferred-length-upload', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: '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'], + [], + [], + [], + [], + [], + ], + 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', + ], + eventKinds: ['upload-url-available', 'progress', 'chunk-complete', 'success', 'source-close'], + eventPolicy: { + deferredLengthBytesTotal: 'allow-known-total-before-declaration', + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [], + featureId: 'deferredLengthUpload', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload'], + primitives: ['defer-upload-length', 'emit-chunk-complete', 'emit-progress'], + requests: [ + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: 0, + 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': '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', + expectedUrl: 'https://tus.io/uploads/deferred-chunked-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: null, + 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, + effectiveHeaders: { + 'Upload-Offset': '10', + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'upload-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/deferred-chunked-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 1, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + '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, + effectiveHeaders: { + 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'upload-final-chunk', + uploadUrl: null, + url: 'upload', + requestIndex: 3, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + effectiveMethod: 'PATCH', + expectedUrl: 'https://tus.io/uploads/deferred-chunked-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'deferredLengthChunkedUpload', + }, + { + behavior: 'override-patch-method', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/override-contract', + eventKeyAlternativeGroups: [], + eventKeyExtraPrefixes: [], + eventKeys: [], + eventKinds: [], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'overridePatchMethod', + 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', + }, + operationIds: ['getTusUploadOffset', 'patchTusUpload'], + primitives: ['override-patch-method'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/override-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 8, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '3', + }, + 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: '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', + expectedUrl: 'https://tus.io/uploads/override-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: true, + value: 'contract-override-fingerprint', + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'overridePatchMethod', + }, + { + behavior: 'parallel-upload-concat', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/parallel-final', + eventKeyAlternativeGroups: [[], [], [], []], + eventKeyExtraPrefixes: ['progress:'], + eventKeys: [ + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + ], + eventKinds: ['progress', 'chunk-complete'], + eventPolicy: { + matching: 'exact-except-allowed-extra-events', + progress: 'milestone', + transportProgress: 'may-emit-extra-samples', + }, + executionActionPhases: [ + { + actions: [ + { + gateId: 'parallel-patches', + heldRequestIndexes: [2, 3], + kind: 'release-after-all-started', + releaseAfterRequestIndexes: [2, 3], + timeoutMs: 2000, + }, + ], + phase: 'serverRequestGates', + }, + ], + featureId: 'parallelUploadConcat', + 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', + }, + operationIds: [ + 'createTusUpload', + 'createTusUpload', + 'patchTusUpload', + 'patchTusUpload', + 'createTusUpload', + ], + primitives: ['concatenate-partial-uploads', 'emit-progress'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Metadata': 'test d29ybGQ=', + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/parallel-part-1', + }, + 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-Metadata': 'test d29ybGQ=', + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Metadata': 'test d29ybGQ=', + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/parallel-part-2', + }, + 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-Metadata': 'test d29ybGQ=', + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: null, + 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': '5', + }, + 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', + expectedUrl: 'https://tus.io/uploads/parallel-part-1', + }, + { + absentHeaders: [], + abort: false, + bodySize: 6, + bodyStart: 5, + 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': '6', + }, + 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', + expectedUrl: 'https://tus.io/uploads/parallel-part-2', + }, + { + absentHeaders: ['Upload-Length'], + abort: false, + bodySize: null, + bodyStart: null, + 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', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/parallel-final', + }, + 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-Metadata': 'foo aGVsbG8=', + 'Upload-Concat': + 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'parallelUploadConcat', + }, + { + behavior: 'parallel-upload-abort-cleanup', + completionKind: 'aborted', + completionMessage: null, + completionReason: null, + completionUploadUrl: null, + eventKeyAlternativeGroups: [[]], + eventKeyExtraPrefixes: [], + eventKeys: ['request-abort:3'], + eventKinds: ['request-abort'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [ + { + actions: [ + { + gateId: 'parallel-cleanup-patches', + heldRequestIndexes: [2, 3], + kind: 'release-after-all-started', + releaseAfterRequestIndexes: [2, 3], + timeoutMs: 2000, + }, + ], + phase: 'serverRequestGates', + }, + ], + featureId: 'parallelUploadConcat', + 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', + }, + operationIds: [ + 'createTusUpload', + 'createTusUpload', + 'patchTusUpload', + 'patchTusUpload', + 'terminateTusUpload', + 'terminateTusUpload', + ], + primitives: ['abort-current-request', 'terminate-upload', 'concatenate-partial-uploads'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + 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, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/parallel-cleanup-part-1', + }, + 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-Metadata': 'test d29ybGQ=', + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + 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, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/parallel-cleanup-part-2', + }, + 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-Metadata': 'test d29ybGQ=', + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: 0, + 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, + operationId: 'patchTusUpload', + response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, + statusCode: 500, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + }, + 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-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', + }, + { + absentHeaders: [], + abort: true, + bodySize: 6, + bodyStart: 5, + 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, + operationId: 'patchTusUpload', + response: null, + role: 'upload-partial-chunk', + 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-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', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-1', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/parallel-cleanup-part-2', + }, + ], + 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', + }, + { + behavior: 'retry-patch-after-offset-recovery', + completionKind: 'success', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/retry-contract', + eventKeyAlternativeGroups: [[], [], [], []], + eventKeyExtraPrefixes: [], + eventKeys: [ + 'should-retry:0:true', + 'retry-schedule:0', + 'should-retry:0:true', + 'retry-schedule:0', + ], + eventKinds: ['should-retry', 'retry-schedule'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'retryOffsetRecovery', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + { + key: 'retryDelays', + value: [0], + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: [ + 'createTusUpload', + 'patchTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + ], + primitives: ['retry-with-backoff', 'recover-offset-after-error'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/retry-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 11, + bodyStart: null, + 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: {}, + headersSpecified: false, + statusCode: 500, + effectiveHeaders: { + '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', + expectedUrl: 'https://tus.io/uploads/retry-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/retry-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 6, + bodyStart: null, + 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: {}, + headersSpecified: false, + statusCode: 500, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + }, + 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', + expectedUrl: 'https://tus.io/uploads/retry-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/retry-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: 6, + bodyStart: null, + 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': '11', + }, + 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', + expectedUrl: 'https://tus.io/uploads/retry-contract', + }, + ], + retryDecisions: [ + { + decision: true, + retryAttempt: 0, + }, + { + decision: true, + retryAttempt: 0, + }, + ], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'retryPatchAfterOffsetRecovery', + }, + { + behavior: 'request-lifecycle-hooks', + 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'], + eventKinds: ['before-request', 'after-response', 'success', 'source-close'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [], + featureId: 'requestLifecycleHooks', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'uploadUrl', + value: 'https://tus.io/uploads/request-hooks-contract', + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['getTusUploadOffset'], + primitives: ['run-request-hooks'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/request-hooks-contract', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'requestLifecycleHooks', + }, + { + behavior: 'abort-upload', + completionKind: 'aborted', + completionMessage: null, + completionReason: null, + completionUploadUrl: null, + eventKeyAlternativeGroups: [[]], + eventKeyExtraPrefixes: [], + eventKeys: ['request-abort:0'], + eventKinds: ['request-abort'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [ + { + actions: [ + { + kind: 'cancel-upload', + requestIndex: 0, + }, + ], + phase: 'onRequestStart', + }, + ], + featureId: 'abortUpload', + inputOptionEntries: [ + { + key: 'endpointUrl', + value: 'https://tus.io/uploads', + }, + { + key: 'metadata', + value: { + filename: 'hello.txt', + }, + }, + ], + inputSource: { + content: 'hello world', + kind: 'blob', + }, + operationIds: ['createTusUpload'], + primitives: ['abort-current-request'], + requests: [ + { + absentHeaders: [], + abort: true, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: null, + role: 'create-upload', + uploadUrl: null, + url: 'endpoint', + requestIndex: 0, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads', + }, + ], + retryDecisions: [], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'abortUpload', + }, + { + behavior: 'abort-upload-after-stored-url', + completionKind: 'aborted', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/abort-terminate-contract', + eventKeyAlternativeGroups: [[]], + eventKeyExtraPrefixes: [], + eventKeys: ['request-abort:1'], + eventKinds: ['request-abort'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [ + { + actions: [ + { + kind: 'cancel-upload', + requestIndex: 1, + }, + ], + phase: 'onRequestStart', + }, + ], + featureId: 'abortUpload', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload', 'terminateTusUpload'], + primitives: ['abort-current-request', 'terminate-upload'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + 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: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/abort-terminate-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: true, + bodySize: 11, + bodyStart: null, + 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, + operationId: 'patchTusUpload', + response: null, + role: 'abort-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': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + 'X-HTTP-Method-Override': 'PATCH', + }, + effectiveMethod: 'POST', + expectedUrl: 'https://tus.io/uploads/abort-terminate-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: 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, + 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', + expectedUrl: 'https://tus.io/uploads/abort-terminate-contract', + }, + ], + 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', + }, + { + behavior: 'terminate-with-retry', + completionKind: 'terminated', + completionMessage: null, + completionReason: null, + completionUploadUrl: 'https://tus.io/uploads/terminate-contract', + eventKeyAlternativeGroups: [[], []], + eventKeyExtraPrefixes: [], + eventKeys: ['should-retry:0:true', 'retry-schedule:0'], + eventKinds: ['should-retry', 'retry-schedule'], + eventPolicy: { + matching: 'exact', + }, + executionActionPhases: [ + { + actions: [ + { + kind: 'abort-upload', + terminateUpload: true, + }, + ], + phase: 'onChunkComplete', + }, + ], + featureId: 'terminateUpload', + 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', + }, + operationIds: ['createTusUpload', 'patchTusUpload', 'terminateTusUpload', 'terminateTusUpload'], + primitives: ['terminate-upload', 'retry-with-backoff'], + requests: [ + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: { + 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + headersSpecified: true, + method: null, + operationId: 'createTusUpload', + response: { + body: null, + headerMode: null, + headers: { + Location: 'https://tus.io/uploads/terminate-contract', + }, + 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', + expectedUrl: 'https://tus.io/uploads', + }, + { + absentHeaders: [], + abort: false, + bodySize: 5, + bodyStart: null, + 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': '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', + expectedUrl: 'https://tus.io/uploads/terminate-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, + operationId: 'terminateTusUpload', + response: { + body: null, + headerMode: null, + headers: {}, + headersSpecified: false, + statusCode: 423, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + }, + role: 'terminate-upload', + uploadUrl: null, + url: 'upload', + requestIndex: 2, + effectiveHeaders: { + 'Tus-Resumable': '1.0.0', + }, + effectiveMethod: 'DELETE', + expectedUrl: 'https://tus.io/uploads/terminate-contract', + }, + { + absentHeaders: [], + abort: false, + bodySize: null, + bodyStart: null, + errorMessage: null, + headerMode: null, + headers: {}, + headersSpecified: false, + method: null, + operationId: 'terminateTusUpload', + response: { + body: null, + headerMode: null, + 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', + expectedUrl: 'https://tus.io/uploads/terminate-contract', + }, + ], + retryDecisions: [ + { + decision: true, + retryAttempt: 0, + }, + ], + runtimeSetup: { + abort: { + terminateUpload: false, + }, + fingerprint: { + install: false, + value: null, + }, + requestId: { + enabled: false, + generatedRequestId: null, + }, + urlStorage: { + install: false, + storedUpload: null, + }, + }, + scenarioId: 'terminateWithRetry', + }, +] + +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: [ + { + 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/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/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-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 621cffc02..7b00aceeb 100644 --- a/test/spec/test-browser-specific.js +++ b/test/spec/test-browser-specific.js @@ -1,6 +1,12 @@ import { defaultOptions, Upload } from 'tus-js-client' -import { assertUrlStorage } from './helpers/assertUrlStorage.js' -import { TestHttpStack, wait, waitableFunction } from './helpers/utils.js' +import { tusClientUrlStorageConformanceScenarios } from './generated-protocol-contract.js' +import { assertUrlStorage, findUrlStorageScenario } from './helpers/assertUrlStorage.js' +import { + expectTusDefaultRequestHeaders, + TestHttpStack, + wait, + waitableFunction, +} from './helpers/utils.js' describe('tus', () => { beforeEach(() => { @@ -45,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, @@ -58,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) @@ -172,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, @@ -195,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) @@ -320,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) @@ -370,7 +376,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-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 new file mode 100644 index 000000000..354e30c3b --- /dev/null +++ b/test/spec/test-generated-protocol-contract.js @@ -0,0 +1,796 @@ +import { defaultOptions, Upload } from 'tus-js-client' +import { + tusClientConformanceEventKeyTemplates, + tusClientConformanceScenarios, + tusClientFeatures, + tusClientScenarioProofCases, + tusManagedUpload, + tusManagedUploadProofCases, + tusProtocolOperations, +} from './generated-protocol-contract.js' +import { + defaultProtocolResponseHeaders, + getBlob, + TestHttpStack, + wait, + 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 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 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 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) + .every((field) => requestHeaders[field.displayName] != null) +} + +function expectRequestMatchesOperation(req, operation, request) { + expect(req.method).toBe(request.effectiveMethod ?? request.method ?? operation.method) + + if (request.headerMode === 'exact') { + return + } + + const expectedHeaders = request.effectiveHeaders ?? request.headers ?? {} + const expectedContentType = expectedHeaders['Content-Type'] ?? operation.request.contentType + if (expectedContentType) { + expect(req.requestHeaders['Content-Type']).toBe(expectedContentType) + } else { + expect(req.requestHeaders['Content-Type']).toBeUndefined() + } + + if (operation.request.headerVariants.length > 0) { + expect( + operation.request.headerVariants.some((variant) => + requestMatchesHeaderVariant(req.requestHeaders, variant), + ), + ).toBe(true) + } +} + +function getOperationResponse(operation, statusCode) { + return operation.responses.find((candidate) => candidate.statusCode === statusCode) +} + +function responseHeadersFor(response, overrides = {}) { + const headers = {} + const variant = response.headerVariants[0] + const defaultResponseHeaders = defaultProtocolResponseHeaders() + for (const field of variant?.fields ?? []) { + if (!field.required) continue + if (overrides[field.displayName] != null) { + headers[field.displayName] = overrides[field.displayName] + continue + } + + if (defaultResponseHeaders[field.displayName] != null) { + headers[field.displayName] = defaultResponseHeaders[field.displayName] + continue + } + + throw new Error( + `Generated scenario response is missing ${field.displayName} for a required ${response.statusCode} response header`, + ) + } + + Object.assign(headers, overrides) + + return headers +} + +function scenarioResponseHeadersFor(operation, response) { + if (response.headerMode === 'exact') { + return response.headers ?? {} + } + + const operationResponse = getOperationResponse(operation, response.statusCode) + if (!operationResponse) { + return response.headers ?? {} + } + + if (response.effectiveHeaders) { + return response.effectiveHeaders + } + + 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 contentBytes(content) { + return new TextEncoder().encode(content) +} + +async function createScenarioInput(input) { + if (input.kind === 'none') { + return null + } + + if (input.kind === 'blob') { + return getBlob(input.content) + } + + 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}`) +} + +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) { + const requestIdSetup = scenario.runtimeSetup.requestId + if (!requestIdSetup.enabled) { + return () => {} + } + + const expectedZeroUuid = '00000000-0000-4000-8000-000000000000' + if (requestIdSetup.generatedRequestId !== expectedZeroUuid) { + throw new Error( + `Generated scenario ${scenario.scenarioId} has unsupported generatedRequestId ${requestIdSetup.generatedRequestId}`, + ) + } + + const originalRandom = Math.random + Math.random = () => 0 + return () => { + Math.random = originalRandom + } +} + +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(storedUpload ? [storedUploadRecord(storedUpload)] : []) + }, + findUploadsByFingerprint(fingerprint) { + const uploads = + storedUpload && storedUpload.fingerprint === fingerprint + ? [storedUploadRecord(storedUpload)] + : [] + observedEvents.push({ count: uploads.length, fingerprint, kind: 'url-storage-find' }) + return Promise.resolve(uploads) + }, + addUpload(fingerprint, upload) { + observedEvents.push({ fingerprint, kind: 'url-storage-add', uploadUrl: upload.uploadUrl }) + return Promise.resolve(upload.urlStorageKey ?? `${fingerprint}-generated-key`) + }, + removeUpload(urlStorageKey) { + observedEvents.push({ kind: 'url-storage-remove', urlStorageKey }) + return Promise.resolve() + }, + } +} + +function scenarioWantsEvent(scenario, kind) { + return scenario.eventKinds.includes(kind) +} + +function scenarioExecutionActions(scenario, phase) { + return ( + scenario.executionActionPhases.find((candidate) => candidate.phase === phase)?.actions ?? [] + ) +} + +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.inputSource.kind, + kind: 'source-open', + size: source.size, + }) + } + + return { + get size() { + return source.size + }, + close() { + observedEvents.push({ kind: 'source-close' }) + source.close() + }, + slice(start, end) { + return source.slice(start, end) + }, + } + }, + } +} + +const eventKeyTemplateByKind = new Map( + tusClientConformanceEventKeyTemplates.map((template) => [template.eventKind, template]), +) + +function eventTemplateFieldValue(event, field) { + const value = event[field.name] + if (value == null) { + if (field.valueKind.startsWith('nullable-')) { + return 'null' + } + + throw new Error( + `Generated observed event ${event.kind} is missing non-nullable key field ${field.name}`, + ) + } + + return String(value) +} + +function observedEventKey(event) { + const template = eventKeyTemplateByKind.get(event.kind) + if (!template) { + throw new Error(`Generated observed event ${event.kind} has no event-key template`) + } + + const parts = template.fields.map((field) => eventTemplateFieldValue(event, field)) + return parts.length === 0 ? event.kind : [event.kind, ...parts].join(':') +} + +function hasAllowedExtraEventPrefix(scenario, eventKey) { + return scenario.eventKeyExtraPrefixes.some((prefix) => eventKey.startsWith(prefix)) +} + +function expectScenarioEventsExactExceptAllowedExtraEvents( + scenario, + observedEvents, + observedEventKeys, + expectedEventKeys, +) { + let expectedIndex = 0 + + for (const observedEventKey of observedEventKeys) { + if (observedEventKey === expectedEventKeys[expectedIndex]) { + expectedIndex += 1 + continue + } + + expect(hasAllowedExtraEventPrefix(scenario, observedEventKey)) + .withContext( + `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) + } + + 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) +} + +function expectScenarioEvents(scenario, observedEvents) { + const expectedEventKeys = scenario.eventKeys + const observedEventKeys = observedEvents.map(observedEventKey) + const eventPolicy = scenario.eventPolicy + + if (eventPolicy.matching === 'exact-except-allowed-extra-events') { + expectScenarioEventsExactExceptAllowedExtraEvents( + scenario, + observedEvents, + observedEventKeys, + expectedEventKeys, + ) + return + } + + 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 expectScenarioRequest(req, request) { + const operation = getProtocolOperation(request.operationId) + + expect(req.url).toBe(request.expectedUrl) + expectRequestMatchesOperation(req, operation, request) + + for (const [header, value] of Object.entries(request.effectiveHeaders ?? 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) + } + + if (!request.response) { + return + } + + req.respondWith({ + status: request.response.statusCode, + responseHeaders: scenarioResponseHeadersFor(operation, request.response), + responseText: request.response.body ?? '', + }) +} + +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() + } + + const abortPromise = upload.abort(scenario.runtimeSetup.abort.terminateUpload) + await wait(0) + + expect(req.method).toBe(request.effectiveMethod ?? request.method ?? operation.method) + expect(req.url).toBe(request.expectedUrl) + + return abortPromise +} + +async function startScenarioUpload(scenario, testStack) { + let upload + let terminatePromise + let afterResponseRequestIndex = 0 + let beforeRequestIndex = 0 + let retryDecisionIndex = 0 + const observedEvents = [] + const retryTimerRecorder = installRetryTimerRecorder(scenario, observedEvents) + const restoreRequestIdRandom = installGeneratedRequestIdRandom(scenario) + const onError = waitableFunction('onError') + const onSuccess = waitableFunction('onSuccess') + const options = { + httpStack: testStack, + onError, + onSuccess(payload) { + if (scenarioWantsEvent(scenario, 'success')) { + observedEvents.push({ kind: 'success' }) + } + onSuccess(payload) + }, + } + + 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' }) + } + } + + if (scenarioWantsEvent(scenario, 'upload-url-available')) { + options.onUploadUrlAvailable = () => { + observedEvents.push({ kind: 'upload-url-available' }) + } + } + + if (scenarioWantsEvent(scenario, 'source-open') || scenarioWantsEvent(scenario, 'source-close')) { + options.fileReader = makeEventRecordingFileReader( + defaultOptions.fileReader, + scenario, + observedEvents, + ) + } + + if (scenario.retryDecisions.length > 0) { + options.onShouldRetry = (_error, retryAttempt) => { + const retryDecision = scenario.retryDecisions[retryDecisionIndex] + if (!retryDecision) { + throw new Error( + `Generated scenario ${scenario.scenarioId} received unexpected retry decision request ${retryDecisionIndex}`, + ) + } + + observedEvents.push({ + decision: retryDecision.decision, + kind: 'should-retry', + retryAttempt, + }) + if (retryDecision.decision) { + retryTimerRecorder.allowNextSchedule() + } + retryDecisionIndex += 1 + return retryDecision.decision + } + } + + for (const entry of scenario.inputOptionEntries) { + applyScenarioInputOption(options, entry) + } + + const fingerprintSetup = scenario.runtimeSetup.fingerprint + if (fingerprintSetup.install) { + options.fingerprint = jasmine.createSpy('fingerprint').and.callFake(() => { + const fingerprint = fingerprintSetup.value + if (scenarioWantsEvent(scenario, 'fingerprint')) { + observedEvents.push({ fingerprint, kind: 'fingerprint' }) + } + return Promise.resolve(fingerprint) + }) + } + + const urlStorageSetup = scenario.runtimeSetup.urlStorage + if (urlStorageSetup.install) { + options.urlStorage = makeEventRecordingUrlStorage(urlStorageSetup.storedUpload, observedEvents) + } + + 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' }) + } + 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.inputSource), options) + + 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() + + return { + observedEvents, + onError, + onSuccess, + restoreRequestIdRandom, + restoreRetryTimerRecorder: retryTimerRecorder.restore, + terminatePromise: () => terminatePromise, + upload, + } +} + +function installRetryTimerRecorder(scenario, observedEvents) { + if (!scenarioWantsEvent(scenario, 'retry-schedule')) { + return { + allowNextSchedule() {}, + restore() {}, + } + } + + const originalSetTimeout = globalThis.setTimeout + let allowedScheduleCount = 0 + globalThis.setTimeout = (handler, delay, ...args) => { + if (allowedScheduleCount > 0) { + observedEvents.push({ delay, kind: 'retry-schedule' }) + allowedScheduleCount -= 1 + } + return originalSetTimeout(handler, delay, ...args) + } + + return { + allowNextSchedule() { + allowedScheduleCount += 1 + }, + restore() { + globalThis.setTimeout = originalSetTimeout + }, + } +} + +async function runGeneratedConformanceScenario(scenario) { + const feature = getClientFeature(scenario.featureId) + expect(feature.primitives).toEqual(jasmine.arrayContaining(scenario.primitives)) + + const testStack = new TestHttpStack() + const { + observedEvents, + onError, + onSuccess, + restoreRetryTimerRecorder, + restoreRequestIdRandom, + terminatePromise, + upload, + } = await startScenarioUpload(scenario, testStack) + const abortPromises = [] + + try { + for (const [scenarioRequestIndex, request] of scenario.requests.entries()) { + const requestIndex = request.requestIndex + expect(requestIndex).toBe(scenarioRequestIndex) + const req = await testStack.nextRequest() + expectScenarioRequest(req, request) + + if (request.abort) { + abortPromises.push( + abortScenarioRequest(req, scenario, request, requestIndex, observedEvents, upload), + ) + } 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`, + ) + } + } + + if (scenario.completionKind === 'aborted') { + await Promise.all(abortPromises) + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) + return + } + + if (scenario.completionKind === 'terminated') { + await terminatePromise() + expect(upload.url).toBe(scenario.completionUploadUrl) + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) + return + } + + if (scenario.completionKind === 'error') { + const err = await onError.toBeCalled() + expect(err.message).toBe(scenario.completionMessage) + 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.completionUploadUrl) + expect(onError).not.toHaveBeenCalled() + expectScenarioEvents(scenario, observedEvents) + } finally { + restoreRequestIdRandom() + restoreRetryTimerRecorder() + } +} + +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)) + }) + } + + 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.completionKind).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) + } + }) + + 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, + ) + } + }) +}) diff --git a/test/spec/test-node-specific.js b/test/spec/test-node-specific.js index 290978efe..a63d744c0 100644 --- a/test/spec/test-node-specific.js +++ b/test/spec/test-node-specific.js @@ -11,8 +11,9 @@ 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 { TestHttpStack, waitableFunction } from './helpers/utils.js' +import { tusClientUrlStorageConformanceScenarios } from './generated-protocol-contract.js' +import { assertUrlStorage, findUrlStorageScenario } from './helpers/assertUrlStorage.js' +import { expectTusDefaultRequestHeaders, TestHttpStack, waitableFunction } from './helpers/utils.js' describe('tus', () => { describe('#canStoreURLs', () => { @@ -126,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, @@ -161,7 +162,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,68 +181,79 @@ 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') + expectTusDefaultRequestHeaders(firstPartCreateRequest.requestHeaders) + 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') + expectTusDefaultRequestHeaders(secondPartCreateRequest.requestHeaders) + 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') + expectTusDefaultRequestHeaders(firstPartPatchRequest.requestHeaders) + 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') + expectTusDefaultRequestHeaders(secondPartPatchRequest.requestHeaders) + 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') + 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', @@ -258,7 +269,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) }) @@ -284,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, @@ -358,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, @@ -373,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) @@ -393,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'), + ) }) }) 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