From 618a91c873ae12d1d467cb4e18adafcb0d77ee84 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 02:36:38 +0200 Subject: [PATCH 01/17] Add generated assembly feature markers --- packages/node/src/Transloadit.ts | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 18ad3ef8..2ef5f71f 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -575,6 +575,67 @@ export class Transloadit { return result.data } + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async createTusAssembly(fileCount: number): Promise { + return await this._remoteJson< + AssemblyStatusWithUploadUrls, + CreateAssemblyParams & Record + >({ + urlSuffix: '/assemblies', + method: 'post', + params: { + await: false, + steps: { + ':original': { + output_meta: true, + result: 'debug', + robot: '/upload/handle', + }, + }, + }, + fields: { + num_expected_upload_files: fileCount, + }, + }) + } + + // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async waitForAssembly(assemblyUrl: string): Promise { + while (true) { + const result = await this._remoteJson({ + url: assemblyUrl, + isTrustedUrl: true, + method: 'get', + }) + + // Abort polling if the assembly has entered an error state + if (result.error) { + return result + } + + // The polling is done if the assembly is not uploading or executing anymore. + if (result.ok !== 'ASSEMBLY_UPLOADING' && result.ok !== 'ASSEMBLY_EXECUTING') { + return result + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + // + async resumeAssemblyUploads( opts: ResumeAssemblyUploadsOptions, ): Promise { From 380d7a269cb5e0a7464acf90151648db762e4e0e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 02:45:03 +0200 Subject: [PATCH 02/17] Add Node SDK devdock TUS assembly example --- .../api2-devdock-tus-assembly/main.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 packages/node/examples/api2-devdock-tus-assembly/main.ts diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts new file mode 100644 index 00000000..fcdc53f4 --- /dev/null +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -0,0 +1,237 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Upload } from 'tus-js-client' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + fail(`${label} must be an array`) + } + + return value +} + +function readPath(value: unknown, pathParts: readonly unknown[], label: string): unknown { + 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 (isRecord(current) && typeof part === 'string') { + if (!Object.hasOwn(current, part)) { + fail(`${label} path ${JSON.stringify(pathParts)} is missing key ${JSON.stringify(part)}`) + } + current = current[part] + continue + } + + fail(`${label} path ${JSON.stringify(pathParts)} cannot read ${JSON.stringify(part)}`) + } + + return current +} + +function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): unknown { + const spec = requireRecord(valueSpec, label) + if (Object.hasOwn(spec, 'value')) { + return spec.value + } + + const source = requireRecord(spec.source, `${label}.source`) + const root = requireString(source.root, `${label}.source.root`) + const pathParts = requireArray(source.path, `${label}.source.path`) + if (!Object.hasOwn(context, root)) { + fail(`${label} value source root ${JSON.stringify(root)} is unavailable`) + } + + return readPath(context[root], pathParts, label) +} + +function scalarString(value: unknown): string { + if (value === null) { + return 'null' + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + return String(value) +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const parsed: unknown = JSON.parse(await readFile(scenarioPath, 'utf8')) + + return requireRecord(parsed, 'scenario') +} + +function scenarioBytes(uploadConfig: JsonRecord): Buffer { + const source = requireRecord(uploadConfig.source, 'upload.source') + const kind = requireString(source.kind, 'upload.source.kind') + const encoding = requireString(source.encoding, 'upload.source.encoding') + if (kind !== 'bytes') { + fail(`unsupported scenario source kind ${JSON.stringify(kind)}`) + } + + if (encoding !== 'utf8') { + fail(`unsupported scenario source encoding ${JSON.stringify(encoding)}`) + } + + return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') +} + +function uploadMetadata( + uploadConfig: JsonRecord, + scenario: JsonRecord, + createResponse: JsonRecord, +): Record { + const context = { createResponse, scenario } + const metadata: Record = {} + for (const fieldValue of requireArray(uploadConfig.metadata, 'upload.metadata')) { + const field = requireRecord(fieldValue, 'upload.metadata[]') + const name = requireString(field.name, 'upload.metadata[].name') + metadata[name] = scalarString(resolveValue(field.value, context, `upload.metadata.${name}`)) + } + + return metadata +} + +function retryDelays(retries: unknown): number[] { + const retryCount = requireNumber(retries, 'upload.retries') + if (!Number.isInteger(retryCount) || retryCount < 0) { + fail(`unsupported retry count ${JSON.stringify(retryCount)}`) + } + + return Array.from({ length: retryCount }, () => 0) +} + +async function uploadWithTus(scenario: JsonRecord, createResponse: JsonRecord): Promise { + const uploadConfig = requireRecord(scenario.upload, 'upload') + const context = { createResponse, scenario } + const endpoint = scalarString(resolveValue(uploadConfig.tusUrl, context, 'upload.tusUrl')) + const content = scenarioBytes(uploadConfig) + if (uploadConfig.chunkSize !== 'full-file') { + fail(`unsupported chunk size policy ${JSON.stringify(uploadConfig.chunkSize)}`) + } + + return await new Promise((resolve, reject) => { + let upload: Upload | null = null + upload = new Upload(content, { + endpoint, + chunkSize: content.length, + metadata: uploadMetadata(uploadConfig, scenario, createResponse), + retryDelays: retryDelays(uploadConfig.retries), + onError: reject, + onSuccess: () => { + if (!upload?.url) { + reject(new Error('TUS upload did not expose an upload URL')) + return + } + + resolve(upload.url) + }, + }) + + upload.start() + }) +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const createTusAssembly = requireRecord(scenario.createTusAssembly, 'createTusAssembly') + const createInput = requireRecord(createTusAssembly.input, 'createTusAssembly.input') + + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const createResponse = requireRecord( + await client.createTusAssembly(requireNumber(createInput.file_count, 'file_count')), + 'createTusAssembly response', + ) + const uploadUrl = await uploadWithTus(scenario, createResponse) + const status = requireRecord( + await client.waitForAssembly( + requireString(createResponse.assembly_ssl_url, 'createTusAssembly response.assembly_ssl_url'), + ), + 'waitForAssembly response', + ) + + await writeResult({ + createResponse, + uploadUrl, + waitOk: status.ok, + }) + + console.log( + `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${uploadUrl}`, + ) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) From 4c3d9724c3baa6d0f0527fb08b35729c3e60185d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 02:49:53 +0200 Subject: [PATCH 03/17] Mark generated Node SDK endpoint blocks --- packages/node/src/Transloadit.ts | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 2ef5f71f..f41fa408 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -1029,6 +1029,12 @@ export class Transloadit { * @param params optional request options * @returns when the Credential is created */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async createTemplateCredential( params: CreateTemplateCredentialParams, ): Promise { @@ -1039,6 +1045,8 @@ export class Transloadit { }) } + // + /** * Edit a Credential * @@ -1046,6 +1054,12 @@ export class Transloadit { * @param params optional request options * @returns when the Credential is edited */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async editTemplateCredential( credentialId: string, params: CreateTemplateCredentialParams, @@ -1057,12 +1071,20 @@ export class Transloadit { }) } + // + /** * Delete a Credential * * @param credentialId the Credential ID * @returns when the Credential is deleted */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async deleteTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ urlSuffix: `/template_credentials/${credentialId}`, @@ -1070,12 +1092,20 @@ export class Transloadit { }) } + // + /** * Get a Credential * * @param credentialId the Credential ID * @returns when the Credential is retrieved */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ urlSuffix: `/template_credentials/${credentialId}`, @@ -1083,12 +1113,20 @@ export class Transloadit { }) } + // + /** * List all TemplateCredentials * * @param params optional request options * @returns the list of templates */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async listTemplateCredentials( params?: ListTemplateCredentialsParams, ): Promise { @@ -1099,6 +1137,8 @@ export class Transloadit { }) } + // + streamTemplateCredentials(params: ListTemplateCredentialsParams) { return new PaginationStream(async (page) => ({ items: (await this.listTemplateCredentials({ ...params, page })).credentials, @@ -1111,6 +1151,12 @@ export class Transloadit { * @param params optional request options * @returns when the template is created */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async createTemplate(params: CreateTemplateParams): Promise { return await this._remoteJson({ urlSuffix: '/templates', @@ -1119,6 +1165,8 @@ export class Transloadit { }) } + // + /** * Edit an Assembly Template * @@ -1126,6 +1174,12 @@ export class Transloadit { * @param params optional request options * @returns when the template is edited */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async editTemplate(templateId: string, params: EditTemplateParams): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1134,12 +1188,20 @@ export class Transloadit { }) } + // + /** * Delete an Assembly Template * * @param templateId the template ID * @returns when the template is deleted */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async deleteTemplate(templateId: string): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1147,12 +1209,20 @@ export class Transloadit { }) } + // + /** * Get an Assembly Template * * @param templateId the template ID * @returns when the template is retrieved */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getTemplate(templateId: string): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1160,12 +1230,20 @@ export class Transloadit { }) } + // + /** * List all Assembly Templates * * @param params optional request options * @returns the list of templates */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async listTemplates( params?: ListTemplatesParams, ): Promise> { @@ -1176,6 +1254,8 @@ export class Transloadit { }) } + // + streamTemplates(params?: ListTemplatesParams): PaginationStream { return new PaginationStream(async (page) => this.listTemplates({ ...params, page })) } @@ -1187,6 +1267,12 @@ export class Transloadit { * @returns with billing data * @see https://transloadit.com/docs/api/bill-date-get/ */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getBill(month: string): Promise { assert.ok(month, 'month is required') return await this._remoteJson({ @@ -1195,6 +1281,8 @@ export class Transloadit { }) } + // + calcSignature( params: OptionalAuthParams, algorithm?: string, From c647ce0474c02cbe39c38f881695d55163cb6dbc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 03:36:34 +0200 Subject: [PATCH 04/17] Add devdock template lifecycle example --- .../api2-devdock-template-lifecycle/main.ts | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/node/examples/api2-devdock-template-lifecycle/main.ts diff --git a/packages/node/examples/api2-devdock-template-lifecycle/main.ts b/packages/node/examples/api2-devdock-template-lifecycle/main.ts new file mode 100644 index 00000000..116bb020 --- /dev/null +++ b/packages/node/examples/api2-devdock-template-lifecycle/main.ts @@ -0,0 +1,255 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { ApiError, Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface ScenarioContent { + additionalProperties: JsonRecord + steps: JsonRecord +} + +interface TemplateConfig { + content: ScenarioContent + namePrefix: string + requireSignatureAuth: boolean +} + +interface UpdateConfig { + content: ScenarioContent + nameSuffix: string + requireSignatureAuth: boolean +} + +interface TemplateLifecycleScenario { + delete: { + errorCodeIncludes: string + } + list: { + minimumCount: number + pageSize: number + } + scenarioId: string + template: TemplateConfig + update: UpdateConfig +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') { + fail(`${label} must be a boolean`) + } + + return value +} + +function scenarioContent(value: unknown, label: string): ScenarioContent { + const content = requireRecord(value, label) + + return { + additionalProperties: requireRecord( + content.additionalProperties, + `${label}.additionalProperties`, + ), + steps: requireRecord(content.steps, `${label}.steps`), + } +} + +function templateConfig(value: unknown, label: string): TemplateConfig { + const config = requireRecord(value, label) + + return { + content: scenarioContent(config.content, `${label}.content`), + namePrefix: requireString(config.namePrefix, `${label}.namePrefix`), + requireSignatureAuth: requireBoolean( + config.requireSignatureAuth, + `${label}.requireSignatureAuth`, + ), + } +} + +function updateConfig(value: unknown, label: string): UpdateConfig { + const config = requireRecord(value, label) + + return { + content: scenarioContent(config.content, `${label}.content`), + nameSuffix: requireString(config.nameSuffix, `${label}.nameSuffix`), + requireSignatureAuth: requireBoolean( + config.requireSignatureAuth, + `${label}.requireSignatureAuth`, + ), + } +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const list = requireRecord(scenario.list, 'scenario.list') + const deleteConfig = requireRecord(scenario.delete, 'scenario.delete') + + return { + delete: { + errorCodeIncludes: requireString( + deleteConfig.errorCodeIncludes, + 'scenario.delete.errorCodeIncludes', + ), + }, + list: { + minimumCount: requireNumber(list.minimumCount, 'scenario.list.minimumCount'), + pageSize: requireNumber(list.pageSize, 'scenario.list.pageSize'), + }, + scenarioId: requireString(scenario.scenarioId, 'scenario.scenarioId'), + template: templateConfig(scenario.template, 'scenario.template'), + update: updateConfig(scenario.update, 'scenario.update'), + } +} + +function templatePayload(name: string, config: TemplateConfig | UpdateConfig): JsonRecord { + return { + name, + require_signature_auth: config.requireSignatureAuth ? 1 : 0, + template: { + ...config.content.additionalProperties, + steps: config.content.steps, + }, + } +} + +function requireTemplateId(value: unknown, label: string): string { + const template = requireRecord(value, label) + + return requireString(template.id, `${label}.id`) +} + +function templateResult(value: unknown): JsonRecord { + const template = requireRecord(value, 'template response') + const content = requireRecord(template.content, 'template response.content') + const requireSignatureAuth = requireNumber( + template.require_signature_auth, + 'template response.require_signature_auth', + ) + + return { + content, + id: requireString(template.id, 'template response.id'), + name: requireString(template.name, 'template response.name'), + requireSignatureAuth: requireSignatureAuth !== 0, + } +} + +async function deletedGetResult(client: Transloadit, templateId: string): Promise { + try { + await client.getTemplate(templateId) + return { + deletedErrorCode: '', + deletedGetSucceeded: true, + } + } catch (err) { + if (!(err instanceof ApiError)) { + throw err + } + + return { + deletedErrorCode: err.code ?? '', + deletedGetSucceeded: false, + } + } +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const templateName = `${scenario.template.namePrefix}-${Date.now()}` + const created = await client.createTemplate(templatePayload(templateName, scenario.template)) + const templateId = requireTemplateId(created, 'createTemplate response') + let deleteTemplate = true + + try { + const fetched = await client.getTemplate(templateId) + const listed = await client.listTemplates({ pagesize: scenario.list.pageSize }) + const updatedTemplateName = `${templateName}${scenario.update.nameSuffix}` + + await client.editTemplate(templateId, templatePayload(updatedTemplateName, scenario.update)) + const updated = await client.getTemplate(templateId) + + await client.deleteTemplate(templateId) + deleteTemplate = false + + await writeResult({ + ...(await deletedGetResult(client, templateId)), + fetched: templateResult(fetched), + listCount: listed.count, + templateId, + templateName, + updated: templateResult(updated), + updatedTemplateName, + }) + } finally { + if (deleteTemplate) { + await client.deleteTemplate(templateId) + } + } + + console.log(`Node SDK devdock scenario ${scenario.scenarioId} passed for ${templateId}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) From fbaaf0fc5a783540d77f1e3922bd0b4098fc9310 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 05:37:05 +0200 Subject: [PATCH 05/17] Read TUS scenario preparations generically --- .../api2-devdock-tus-assembly/main.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index fcdc53f4..1ae288af 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -97,6 +97,28 @@ function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): u return readPath(context[root], pathParts, label) } +function featurePreparation( + scenario: JsonRecord, + featureId: string, +): { label: string; preparation: JsonRecord } { + const preparations = requireArray(scenario.preparations, 'preparations') + for (const [index, rawPreparation] of preparations.entries()) { + const label = `preparations[${index}]` + const preparation = requireRecord(rawPreparation, label) + if (requireString(preparation.featureId, `${label}.featureId`) !== featureId) { + continue + } + + if (requireString(preparation.kind, `${label}.kind`) !== 'feature-call') { + fail(`${label} must be a feature-call preparation`) + } + + return { label, preparation } + } + + fail(`scenario has no preparation for feature ${JSON.stringify(featureId)}`) +} + function scalarString(value: unknown): string { if (value === null) { return 'null' @@ -199,8 +221,11 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const createTusAssembly = requireRecord(scenario.createTusAssembly, 'createTusAssembly') - const createInput = requireRecord(createTusAssembly.input, 'createTusAssembly.input') + const { label: createTusAssemblyLabel, preparation: createTusAssembly } = featurePreparation( + scenario, + 'createTusAssembly', + ) + const createInput = requireRecord(createTusAssembly.input, `${createTusAssemblyLabel}.input`) const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), From 95f7b0e54d5a4a2e344b2d03e5c47e23fc81de27 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 08:03:08 +0200 Subject: [PATCH 06/17] Generate TUS assembly upload helper --- .../api2-devdock-tus-assembly/main.ts | 152 +++--------------- packages/node/src/Transloadit.ts | 103 ++++++++++++ 2 files changed, 126 insertions(+), 129 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index 1ae288af..b4d126eb 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -1,8 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' -import { Upload } from 'tus-js-client' - import { Transloadit } from '../../src/Transloadit.ts' type JsonRecord = Record @@ -56,45 +54,11 @@ function requireArray(value: unknown, label: string): unknown[] { return value } -function readPath(value: unknown, pathParts: readonly unknown[], label: string): unknown { - 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 (isRecord(current) && typeof part === 'string') { - if (!Object.hasOwn(current, part)) { - fail(`${label} path ${JSON.stringify(pathParts)} is missing key ${JSON.stringify(part)}`) - } - current = current[part] - continue - } - - fail(`${label} path ${JSON.stringify(pathParts)} cannot read ${JSON.stringify(part)}`) - } - - return current -} - -function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): unknown { - const spec = requireRecord(valueSpec, label) - if (Object.hasOwn(spec, 'value')) { - return spec.value - } - - const source = requireRecord(spec.source, `${label}.source`) - const root = requireString(source.root, `${label}.source.root`) - const pathParts = requireArray(source.path, `${label}.source.path`) - if (!Object.hasOwn(context, root)) { - fail(`${label} value source root ${JSON.stringify(root)} is unavailable`) - } - - return readPath(context[root], pathParts, label) +function stringRecord(value: unknown, label: string): Record { + const record = requireRecord(value, label) + return Object.fromEntries( + Object.entries(record).map(([key, entryValue]) => [key, String(entryValue)]), + ) } function featurePreparation( @@ -119,18 +83,6 @@ function featurePreparation( fail(`scenario has no preparation for feature ${JSON.stringify(featureId)}`) } -function scalarString(value: unknown): string { - if (value === null) { - return 'null' - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - - return String(value) -} - async function loadScenario(): Promise { const scenarioPath = process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') @@ -139,6 +91,13 @@ async function loadScenario(): Promise { return requireRecord(parsed, 'scenario') } +function fileCount(scenario: JsonRecord): number { + const { label, preparation } = featurePreparation(scenario, 'createTusAssembly') + const input = requireRecord(preparation.input, `${label}.input`) + + return requireNumber(input.file_count, `${label}.input.file_count`) +} + function scenarioBytes(uploadConfig: JsonRecord): Buffer { const source = requireRecord(uploadConfig.source, 'upload.source') const kind = requireString(source.kind, 'upload.source.kind') @@ -154,62 +113,6 @@ function scenarioBytes(uploadConfig: JsonRecord): Buffer { return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') } -function uploadMetadata( - uploadConfig: JsonRecord, - scenario: JsonRecord, - createResponse: JsonRecord, -): Record { - const context = { createResponse, scenario } - const metadata: Record = {} - for (const fieldValue of requireArray(uploadConfig.metadata, 'upload.metadata')) { - const field = requireRecord(fieldValue, 'upload.metadata[]') - const name = requireString(field.name, 'upload.metadata[].name') - metadata[name] = scalarString(resolveValue(field.value, context, `upload.metadata.${name}`)) - } - - return metadata -} - -function retryDelays(retries: unknown): number[] { - const retryCount = requireNumber(retries, 'upload.retries') - if (!Number.isInteger(retryCount) || retryCount < 0) { - fail(`unsupported retry count ${JSON.stringify(retryCount)}`) - } - - return Array.from({ length: retryCount }, () => 0) -} - -async function uploadWithTus(scenario: JsonRecord, createResponse: JsonRecord): Promise { - const uploadConfig = requireRecord(scenario.upload, 'upload') - const context = { createResponse, scenario } - const endpoint = scalarString(resolveValue(uploadConfig.tusUrl, context, 'upload.tusUrl')) - const content = scenarioBytes(uploadConfig) - if (uploadConfig.chunkSize !== 'full-file') { - fail(`unsupported chunk size policy ${JSON.stringify(uploadConfig.chunkSize)}`) - } - - return await new Promise((resolve, reject) => { - let upload: Upload | null = null - upload = new Upload(content, { - endpoint, - chunkSize: content.length, - metadata: uploadMetadata(uploadConfig, scenario, createResponse), - retryDelays: retryDelays(uploadConfig.retries), - onError: reject, - onSuccess: () => { - if (!upload?.url) { - reject(new Error('TUS upload did not expose an upload URL')) - return - } - - resolve(upload.url) - }, - }) - - upload.start() - }) -} - async function writeResult(result: JsonRecord): Promise { const resultPath = process.env.API2_SDK_EXAMPLE_RESULT if (!resultPath) { @@ -221,38 +124,29 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const { label: createTusAssemblyLabel, preparation: createTusAssembly } = featurePreparation( - scenario, - 'createTusAssembly', - ) - const createInput = requireRecord(createTusAssembly.input, `${createTusAssemblyLabel}.input`) - + const upload = requireRecord(scenario.upload, 'upload') const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), authSecret: requiredEnv('TRANSLOADIT_SECRET'), endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), }) - const createResponse = requireRecord( - await client.createTusAssembly(requireNumber(createInput.file_count, 'file_count')), - 'createTusAssembly response', - ) - const uploadUrl = await uploadWithTus(scenario, createResponse) - const status = requireRecord( - await client.waitForAssembly( - requireString(createResponse.assembly_ssl_url, 'createTusAssembly response.assembly_ssl_url'), - ), - 'waitForAssembly response', + const result = await client.uploadTusAssembly( + fileCount(scenario), + scenarioBytes(upload), + requireString(upload.fieldName, 'upload.fieldName'), + requireString(upload.fileName, 'upload.fileName'), + stringRecord(upload.userMeta, 'upload.userMeta'), ) await writeResult({ - createResponse, - uploadUrl, - waitOk: status.ok, + createResponse: result.assembly, + uploadUrl: result.uploadUrl, + waitOk: result.assembly.ok, }) console.log( - `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${uploadUrl}`, + `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${result.uploadUrl}`, ) } diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index f41fa408..a9f1965c 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -108,6 +108,11 @@ export type AssemblyStatusWithUploadUrls = AssemblyStatus & { upload_urls?: Record } +export interface UploadTusAssemblyResult { + assembly: AssemblyStatus + uploadUrl: string +} + const { version } = packageJson export type AssemblyProgress = (assembly: AssemblyStatus) => void @@ -636,6 +641,104 @@ export class Transloadit { // + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async uploadTusAssembly( + fileCount: number, + content: Buffer | Uint8Array | string, + fieldname: string, + filename: string, + userMeta: Record, + ): Promise { + const createdAssembly = await this.createTusAssembly(fileCount) + + const endpointUrl = createdAssembly.tus_url + if (!endpointUrl) { + throw new Error('TUS singleUploadLifecycle needs input.endpointUrl') + } + + const contentBytes = Buffer.isBuffer(content) ? content : Buffer.from(content) + + const metadataMap = new Map() + for (const [key, value] of Object.entries(userMeta)) { + metadataMap.set(String(key), String(value)) + } + metadataMap.set('assembly_url', String(createdAssembly.assembly_url)) + metadataMap.set('fieldname', String(fieldname)) + metadataMap.set('filename', String(filename)) + + const createHeaders: Record = {} + createHeaders['Tus-Resumable'] = '1.0.0' + createHeaders['Upload-Length'] = String(contentBytes.length) + const createMetadataParts: string[] = [] + for (const [key, value] of metadataMap) { + const encodedValue = Buffer.from(String(value), 'utf8').toString('base64') + createMetadataParts.push(`${key} ${encodedValue}`) + } + createHeaders['Upload-Metadata'] = createMetadataParts.join(',') + const createResponse = await got(endpointUrl, { + method: 'POST', + body: Buffer.alloc(0), + headers: createHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (createResponse.statusCode !== 201) { + throw new Error(`TUS create returned HTTP ${createResponse.statusCode}, expected 201`) + } + const uploadUrlLocation = createResponse.headers.location + const uploadUrlLocationText = Array.isArray(uploadUrlLocation) + ? uploadUrlLocation[0] + : uploadUrlLocation + if (!uploadUrlLocationText) { + throw new Error('TUS create did not return a Location header') + } + const uploadUrlText = new URL(uploadUrlLocationText, endpointUrl).toString() + + const uploadHeaders: Record = {} + uploadHeaders['Tus-Resumable'] = '1.0.0' + uploadHeaders['Upload-Offset'] = '0' + uploadHeaders['Content-Type'] = 'application/offset+octet-stream' + const uploadResponse = await got(uploadUrlText, { + method: 'PATCH', + body: contentBytes, + headers: uploadHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (uploadResponse.statusCode !== 204) { + throw new Error(`TUS upload returned HTTP ${uploadResponse.statusCode}, expected 204`) + } + const uploadOffsetHeader = uploadResponse.headers['upload-offset'] + const uploadOffsetText = Array.isArray(uploadOffsetHeader) + ? uploadOffsetHeader[0] + : uploadOffsetHeader + if (!uploadOffsetText) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + const remoteOffset = Number(uploadOffsetText) + if (!Number.isInteger(remoteOffset)) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + if (remoteOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${remoteOffset}, expected ${contentBytes.length}`) + } + + const completedAssembly = await this.waitForAssembly(createdAssembly.assembly_ssl_url ?? '') + + return { assembly: completedAssembly, uploadUrl: uploadUrlText } + } + + // + async resumeAssemblyUploads( opts: ResumeAssemblyUploadsOptions, ): Promise { From a5b4c9aa6cb250601e66360227c5cbb876dd68e7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 01:01:35 +0200 Subject: [PATCH 07/17] Use header-derived TUS offset variable --- packages/node/src/Transloadit.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index a9f1965c..5e1768bc 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -724,12 +724,12 @@ export class Transloadit { if (!uploadOffsetText) { throw new Error('TUS upload returned an invalid Upload-Offset header') } - const remoteOffset = Number(uploadOffsetText) - if (!Number.isInteger(remoteOffset)) { + const uploadOffset = Number(uploadOffsetText) + if (!Number.isInteger(uploadOffset)) { throw new Error('TUS upload returned an invalid Upload-Offset header') } - if (remoteOffset !== contentBytes.length) { - throw new Error(`TUS upload offset ${remoteOffset}, expected ${contentBytes.length}`) + if (uploadOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) } const completedAssembly = await this.waitForAssembly(createdAssembly.assembly_ssl_url ?? '') From 0a88f25d197324a6a9acc015e2def5de9711b8df Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 05:05:48 +0200 Subject: [PATCH 08/17] Read TUS example input from SDK feature call --- .../api2-devdock-tus-assembly/main.ts | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index b4d126eb..5b334b53 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -61,26 +61,34 @@ function stringRecord(value: unknown, label: string): Record { ) } -function featurePreparation( +function optionalStringRecord(value: unknown, label: string): Record { + if (value == null) { + return {} + } + + return stringRecord(value, label) +} + +function sdkFeatureCall( scenario: JsonRecord, featureId: string, -): { label: string; preparation: JsonRecord } { - const preparations = requireArray(scenario.preparations, 'preparations') - for (const [index, rawPreparation] of preparations.entries()) { - const label = `preparations[${index}]` - const preparation = requireRecord(rawPreparation, label) - if (requireString(preparation.featureId, `${label}.featureId`) !== featureId) { +): { featureCall: JsonRecord; label: string } { + const featureCalls = requireArray(scenario.sdkFeatureCalls, 'sdkFeatureCalls') + for (const [index, rawFeatureCall] of featureCalls.entries()) { + const label = `sdkFeatureCalls[${index}]` + const featureCall = requireRecord(rawFeatureCall, label) + if (requireString(featureCall.featureId, `${label}.featureId`) !== featureId) { continue } - if (requireString(preparation.kind, `${label}.kind`) !== 'feature-call') { - fail(`${label} must be a feature-call preparation`) + if (requireString(featureCall.kind, `${label}.kind`) !== 'sdk-feature-call') { + fail(`${label} must be an sdk-feature-call`) } - return { label, preparation } + return { featureCall, label } } - fail(`scenario has no preparation for feature ${JSON.stringify(featureId)}`) + fail(`scenario has no SDK feature call for feature ${JSON.stringify(featureId)}`) } async function loadScenario(): Promise { @@ -91,26 +99,17 @@ async function loadScenario(): Promise { return requireRecord(parsed, 'scenario') } -function fileCount(scenario: JsonRecord): number { - const { label, preparation } = featurePreparation(scenario, 'createTusAssembly') - const input = requireRecord(preparation.input, `${label}.input`) +function uploadTusAssemblyInput(scenario: JsonRecord): JsonRecord { + const { featureCall, label } = sdkFeatureCall(scenario, 'uploadTusAssembly') - return requireNumber(input.file_count, `${label}.input.file_count`) + return requireRecord(featureCall.input, `${label}.input`) } function scenarioBytes(uploadConfig: JsonRecord): Buffer { - const source = requireRecord(uploadConfig.source, 'upload.source') - const kind = requireString(source.kind, 'upload.source.kind') - const encoding = requireString(source.encoding, 'upload.source.encoding') - if (kind !== 'bytes') { - fail(`unsupported scenario source kind ${JSON.stringify(kind)}`) - } - - if (encoding !== 'utf8') { - fail(`unsupported scenario source encoding ${JSON.stringify(encoding)}`) - } - - return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') + return Buffer.from( + requireString(uploadConfig.content, 'sdkFeatureCalls.uploadTusAssembly.input.upload.content'), + 'utf8', + ) } async function writeResult(result: JsonRecord): Promise { @@ -124,7 +123,8 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const upload = requireRecord(scenario.upload, 'upload') + const input = uploadTusAssemblyInput(scenario) + const upload = requireRecord(input.upload, 'sdkFeatureCalls.uploadTusAssembly.input.upload') const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), authSecret: requiredEnv('TRANSLOADIT_SECRET'), @@ -132,11 +132,14 @@ async function main(): Promise { }) const result = await client.uploadTusAssembly( - fileCount(scenario), + requireNumber(input.file_count, 'sdkFeatureCalls.uploadTusAssembly.input.file_count'), scenarioBytes(upload), - requireString(upload.fieldName, 'upload.fieldName'), - requireString(upload.fileName, 'upload.fileName'), - stringRecord(upload.userMeta, 'upload.userMeta'), + requireString(upload.fieldname, 'sdkFeatureCalls.uploadTusAssembly.input.upload.fieldname'), + requireString(upload.filename, 'sdkFeatureCalls.uploadTusAssembly.input.upload.filename'), + optionalStringRecord( + upload.user_meta, + 'sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta', + ), ) await writeResult({ From 49bde3b8c08a590234487c9f21f9b776e3c5f09d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 20:51:26 +0200 Subject: [PATCH 09/17] Read SDK example input projection --- .../api2-devdock-tus-assembly/main.ts | 115 ++++++++++-------- 1 file changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index 5b334b53..25c7d90e 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -5,6 +5,29 @@ import { Transloadit } from '../../src/Transloadit.ts' type JsonRecord = Record +interface ExampleInput { + scenarioId: string + sdkFeatureInputs: { + uploadTusAssembly: UploadTusAssemblyInput + } +} + +interface TusAssemblyScenario { + exampleInput: ExampleInput +} + +interface UploadConfig { + content: string + fieldname: string + filename: string + user_meta: Record +} + +interface UploadTusAssemblyInput { + file_count: number + upload: UploadConfig +} + function fail(message: string): never { throw new Error(message) } @@ -46,14 +69,6 @@ function requireNumber(value: unknown, label: string): number { return value } -function requireArray(value: unknown, label: string): unknown[] { - if (!Array.isArray(value)) { - fail(`${label} must be an array`) - } - - return value -} - function stringRecord(value: unknown, label: string): Record { const record = requireRecord(value, label) return Object.fromEntries( @@ -69,47 +84,50 @@ function optionalStringRecord(value: unknown, label: string): Record { +async function loadScenario(): Promise { const scenarioPath = process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') const parsed: unknown = JSON.parse(await readFile(scenarioPath, 'utf8')) + const scenario = requireRecord(parsed, 'scenario') - return requireRecord(parsed, 'scenario') -} - -function uploadTusAssemblyInput(scenario: JsonRecord): JsonRecord { - const { featureCall, label } = sdkFeatureCall(scenario, 'uploadTusAssembly') - - return requireRecord(featureCall.input, `${label}.input`) -} - -function scenarioBytes(uploadConfig: JsonRecord): Buffer { - return Buffer.from( - requireString(uploadConfig.content, 'sdkFeatureCalls.uploadTusAssembly.input.upload.content'), - 'utf8', - ) + return { + exampleInput: exampleInput(scenario.exampleInput, 'scenario.exampleInput'), + } } async function writeResult(result: JsonRecord): Promise { @@ -123,8 +141,8 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const input = uploadTusAssemblyInput(scenario) - const upload = requireRecord(input.upload, 'sdkFeatureCalls.uploadTusAssembly.input.upload') + const input = scenario.exampleInput.sdkFeatureInputs.uploadTusAssembly + const upload = input.upload const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), authSecret: requiredEnv('TRANSLOADIT_SECRET'), @@ -132,14 +150,11 @@ async function main(): Promise { }) const result = await client.uploadTusAssembly( - requireNumber(input.file_count, 'sdkFeatureCalls.uploadTusAssembly.input.file_count'), - scenarioBytes(upload), - requireString(upload.fieldname, 'sdkFeatureCalls.uploadTusAssembly.input.upload.fieldname'), - requireString(upload.filename, 'sdkFeatureCalls.uploadTusAssembly.input.upload.filename'), - optionalStringRecord( - upload.user_meta, - 'sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta', - ), + input.file_count, + Buffer.from(upload.content, 'utf8'), + upload.fieldname, + upload.filename, + upload.user_meta, ) await writeResult({ @@ -149,7 +164,7 @@ async function main(): Promise { }) console.log( - `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${result.uploadUrl}`, + `Node SDK devdock scenario ${scenario.exampleInput.scenarioId} uploaded to ${result.uploadUrl}`, ) } From feefa6dde506fca7b4f0cba2912a4930a9996984 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 17:07:54 +0200 Subject: [PATCH 10/17] Regenerate contract-owned TUS Assembly surfaces --- packages/node/src/Transloadit.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 5e1768bc..08d3d1b5 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -108,11 +108,19 @@ export type AssemblyStatusWithUploadUrls = AssemblyStatus & { upload_urls?: Record } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + export interface UploadTusAssemblyResult { assembly: AssemblyStatus uploadUrl: string } +// + const { version } = packageJson export type AssemblyProgress = (assembly: AssemblyStatus) => void @@ -586,6 +594,9 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. + /** + * Creates a TUS-ready Assembly that waits for the requested number of resumable uploads before execution continues. + */ async createTusAssembly(fileCount: number): Promise { return await this._remoteJson< AssemblyStatusWithUploadUrls, @@ -617,6 +628,10 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. + /** + * Waits for an Assembly to finish uploading and executing. + * Use the returned assembly_ssl_url as the assembly URL. + */ async waitForAssembly(assemblyUrl: string): Promise { while (true) { const result = await this._remoteJson({ @@ -647,6 +662,9 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. + /** + * Creates a TUS-ready Assembly, uploads one file with the TUS protocol, and waits for the Assembly to finish. + */ async uploadTusAssembly( fileCount: number, content: Buffer | Uint8Array | string, From 8b235a10e195e8ab668922b996b0b37d0abe96fb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 17:13:48 +0200 Subject: [PATCH 11/17] Skip coverage publish on pull requests --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d1e9ea..d1b1296c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,7 +229,10 @@ jobs: env: COVERAGE_REPO_SSH_PRIVATE_KEY: ${{ secrets.COVERAGE_REPO_SSH_PRIVATE_KEY }} run: | - if [ -n "$COVERAGE_REPO_SSH_PRIVATE_KEY" ]; then + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Coverage publish skipped for pull request runs." + elif [ -n "$COVERAGE_REPO_SSH_PRIVATE_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" From eaa74e8abd4da469465626bcbb0a579f9f34b7bc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 18:29:58 +0200 Subject: [PATCH 12/17] Regenerate required feature value guards --- packages/node/src/Transloadit.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 08d3d1b5..0693f3e9 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -750,7 +750,11 @@ export class Transloadit { throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) } - const completedAssembly = await this.waitForAssembly(createdAssembly.assembly_ssl_url ?? '') + const createdAssemblyAssemblySslUrl = createdAssembly.assembly_ssl_url + if (!createdAssemblyAssemblySslUrl) { + throw new Error('uploadTusAssembly needs createdAssembly.assembly_ssl_url') + } + const completedAssembly = await this.waitForAssembly(createdAssemblyAssemblySslUrl) return { assembly: completedAssembly, uploadUrl: uploadUrlText } } From c939584bc29c4dda67cc6b4ec317042c3d62bb1b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 08:15:49 +0200 Subject: [PATCH 13/17] Add Assembly lifecycle devdock example --- .../api2-devdock-assembly-lifecycle/main.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/node/examples/api2-devdock-assembly-lifecycle/main.ts diff --git a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts new file mode 100644 index 00000000..9a085a81 --- /dev/null +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -0,0 +1,134 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface AssemblyLifecycleScenario { + assembly: { + fileCount: number + } + list: { + minimumCount: number + pageSize: number + } + scenarioId: string +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const assembly = requireRecord(scenario.assembly, 'scenario.assembly') + const list = requireRecord(scenario.list, 'scenario.list') + + return { + assembly: { + fileCount: requireNumber(assembly.fileCount, 'scenario.assembly.fileCount'), + }, + list: { + minimumCount: requireNumber(list.minimumCount, 'scenario.list.minimumCount'), + pageSize: requireNumber(list.pageSize, 'scenario.list.pageSize'), + }, + scenarioId: requireString(scenario.scenarioId, 'scenario.scenarioId'), + } +} + +function assemblyResult(value: JsonRecord): JsonRecord { + return { + assemblyId: value.assembly_id, + assemblySslUrl: value.assembly_ssl_url, + assemblyUrl: value.assembly_url, + ok: value.ok, + } +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const created = await client.createTusAssembly(scenario.assembly.fileCount) + const assemblyId = requireString(created.assembly_id, 'createTusAssembly response.assembly_id') + let cancelOnExit = true + + try { + const fetched = await client.getAssembly(assemblyId) + const listed = await client.listAssemblies({ pagesize: scenario.list.pageSize }) + const cancelled = await client.cancelAssembly(assemblyId) + cancelOnExit = false + + await writeResult({ + cancelled: assemblyResult(cancelled), + created: assemblyResult(created), + fetched: assemblyResult(fetched), + listContainsCreated: listed.items.some((assembly) => assembly.id === assemblyId), + listCount: listed.count, + }) + } finally { + if (cancelOnExit) { + await client.cancelAssembly(assemblyId) + } + } + + console.log(`Node SDK devdock scenario ${scenario.scenarioId} canceled Assembly ${assemblyId}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) From f44dc76f365df61bec4e3dd81bc87ddb7cf2a8f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 08:23:03 +0200 Subject: [PATCH 14/17] Use SSL Assembly URL for TUS metadata --- packages/node/src/Transloadit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 0693f3e9..137600ee 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -685,7 +685,7 @@ export class Transloadit { for (const [key, value] of Object.entries(userMeta)) { metadataMap.set(String(key), String(value)) } - metadataMap.set('assembly_url', String(createdAssembly.assembly_url)) + metadataMap.set('assembly_url', String(createdAssembly.assembly_ssl_url)) metadataMap.set('fieldname', String(fieldname)) metadataMap.set('filename', String(filename)) From 35ccbbda138571e1a304e5ba071159a26135a6f2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 09:20:06 +0200 Subject: [PATCH 15/17] Filter Assembly lifecycle list by assembly_id The devdock Assembly lifecycle proof listed the first page of Assemblies and could miss the created one on a busy server. The contract models an assembly_id list filter (already used by the Go example), so expose it on ListAssembliesParams and use it in the example. Co-Authored-By: Claude Fable 5 --- .../node/examples/api2-devdock-assembly-lifecycle/main.ts | 5 ++++- packages/node/src/apiTypes.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts index 9a085a81..7d8f1633 100644 --- a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -108,7 +108,10 @@ async function main(): Promise { try { const fetched = await client.getAssembly(assemblyId) - const listed = await client.listAssemblies({ pagesize: scenario.list.pageSize }) + const listed = await client.listAssemblies({ + assembly_id: assemblyId, + pagesize: scenario.list.pageSize, + }) const cancelled = await client.cancelAssembly(assemblyId) cancelOnExit = false diff --git a/packages/node/src/apiTypes.ts b/packages/node/src/apiTypes.ts index 702e42e2..8ec3e83b 100644 --- a/packages/node/src/apiTypes.ts +++ b/packages/node/src/apiTypes.ts @@ -34,6 +34,7 @@ export interface PaginationListWithCount extends PaginationList { export type CreateAssemblyParams = Omit & OptionalAuthParams export type ListAssembliesParams = OptionalAuthParams & { + assembly_id?: string page?: number pagesize?: number type?: 'all' | 'uploading' | 'executing' | 'canceled' | 'completed' | 'failed' | 'request_aborted' From f050b8663f6e82c73992868777b2e9b69aa6353d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 08:40:23 +0200 Subject: [PATCH 16/17] Prove TUS resume upload via generated SDK method resumeTusUpload() is generated from the API2 resumeUpload TUS protocol contract: it discovers the server offset with a HEAD request, PATCHes the remaining bytes from that offset, asserts the final offset matches the content length, and waits for the Assembly to finish. The new api2-devdock-tus-resume-upload example interrupts an upload after the first chunk like a dropped connection would, then resumes it through the public SDK method. Co-Authored-By: Claude Fable 5 --- .../api2-devdock-tus-resume-upload/main.ts | 276 ++++++++++++++++++ packages/node/src/Transloadit.ts | 84 ++++++ 2 files changed, 360 insertions(+) create mode 100644 packages/node/examples/api2-devdock-tus-resume-upload/main.ts diff --git a/packages/node/examples/api2-devdock-tus-resume-upload/main.ts b/packages/node/examples/api2-devdock-tus-resume-upload/main.ts new file mode 100644 index 00000000..4665e3ad --- /dev/null +++ b/packages/node/examples/api2-devdock-tus-resume-upload/main.ts @@ -0,0 +1,276 @@ +// Run the API2 contract TUS resume scenario against a devdock API2 server. +// +// This example is intentionally checked into the SDK repository: it reads the +// API/TUS facts from API2's injected scenario JSON, interrupts an upload like +// an unlucky user would, and resumes it through the public SDK method. +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface MetadataField { + name: string + value: unknown +} + +interface ResumePlan { + fingerprint: string + removeFingerprintOnSuccess: boolean + stopAfterAcceptedBytes: number +} + +interface ResumeUploadScenario { + createResponse: JsonRecord + scenario: JsonRecord + scenarioId: string +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') { + fail(`${label} must be a boolean`) + } + + return value +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const exampleInput = requireRecord(scenario.exampleInput, 'scenario.exampleInput') + const prepared = requireRecord(scenario.prepared, 'scenario.prepared') + + return { + createResponse: requireRecord(prepared.createResponse, 'scenario.prepared.createResponse'), + scenario, + scenarioId: requireString(exampleInput.scenarioId, 'scenario.exampleInput.scenarioId'), + } +} + +function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): unknown { + const spec = requireRecord(valueSpec, `${label} value spec`) + if ('value' in spec) { + return spec.value + } + + const source = requireRecord(spec.source, `${label} value source`) + const root = requireString(source.root, `${label} value source root`) + let current: unknown = context[root] ?? fail(`${label} value source root is unavailable`) + if (!Array.isArray(source.path)) { + fail(`${label} value source path must be an array`) + } + for (const part of source.path) { + const record = requireRecord(current, `${label} value source step`) + current = record[String(part)] + } + + return current +} + +function scenarioBytes(upload: JsonRecord): Buffer { + const source = requireRecord(upload.source, 'upload.source') + if (source.kind !== 'bytes') { + fail('upload.source.kind must be bytes') + } + if (source.encoding !== 'utf8') { + fail('upload.source.encoding must be utf8') + } + + return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') +} + +function resumePlan(upload: JsonRecord): ResumePlan { + const resume = requireRecord(upload.resume, 'upload.resume') + + return { + fingerprint: requireString(resume.fingerprint, 'upload.resume.fingerprint'), + removeFingerprintOnSuccess: requireBoolean( + resume.removeFingerprintOnSuccess, + 'upload.resume.removeFingerprintOnSuccess', + ), + stopAfterAcceptedBytes: requireNumber( + resume.stopAfterAcceptedBytes, + 'upload.resume.stopAfterAcceptedBytes', + ), + } +} + +function uploadMetadata(upload: JsonRecord, context: JsonRecord): Map { + const metadata = new Map() + if (!Array.isArray(upload.metadata)) { + fail('upload.metadata must be an array') + } + for (const fieldValue of upload.metadata) { + const fieldRecord = requireRecord(fieldValue, 'upload.metadata field') + const field: MetadataField = { + name: requireString(fieldRecord.name, 'upload.metadata field.name'), + value: fieldRecord.value, + } + metadata.set(field.name, String(resolveValue(field.value, context, field.name))) + } + + return metadata +} + +// Create a TUS upload and only send the first chunk, leaving the upload +// interrupted the way a dropped connection would. +async function createInterruptedUpload({ + content, + metadata, + stopAfterAcceptedBytes, + tusUrl, +}: { + content: Buffer + metadata: Map + stopAfterAcceptedBytes: number + tusUrl: string +}): Promise { + const metadataParts: string[] = [] + for (const [name, value] of metadata) { + metadataParts.push(`${name} ${Buffer.from(value, 'utf8').toString('base64')}`) + } + const createResponse = await fetch(tusUrl, { + headers: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': String(content.length), + 'Upload-Metadata': metadataParts.join(','), + }, + method: 'POST', + }) + if (createResponse.status !== 201) { + fail(`TUS create returned HTTP ${createResponse.status}, expected 201`) + } + const location = createResponse.headers.get('location') + if (!location) { + fail('TUS create did not return a Location header') + } + const uploadUrl = new URL(location, tusUrl).toString() + + const patchResponse = await fetch(uploadUrl, { + body: new Uint8Array(content.subarray(0, stopAfterAcceptedBytes)), + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Tus-Resumable': '1.0.0', + 'Upload-Offset': '0', + }, + method: 'PATCH', + }) + if (patchResponse.status !== 204) { + fail(`TUS first chunk returned HTTP ${patchResponse.status}, expected 204`) + } + const acceptedBytes = Number(patchResponse.headers.get('upload-offset')) + if (acceptedBytes !== stopAfterAcceptedBytes) { + fail(`TUS first chunk accepted ${acceptedBytes} bytes, expected ${stopAfterAcceptedBytes}`) + } + + return uploadUrl +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const { createResponse, scenario, scenarioId } = await loadScenario() + const upload = requireRecord(scenario.upload, 'scenario.upload') + const resume = resumePlan(upload) + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const context: JsonRecord = { createResponse, scenario } + const content = scenarioBytes(upload) + const tusUrl = requireString(resolveValue(upload.tusUrl, context, 'upload.tusUrl'), 'tusUrl') + const metadata = uploadMetadata(upload, context) + + const firstUploadUrl = await createInterruptedUpload({ + content, + metadata, + stopAfterAcceptedBytes: resume.stopAfterAcceptedBytes, + tusUrl, + }) + + // Remember the interrupted upload by fingerprint, like a TUS client URL storage would. + const storedUploads = new Map([[resume.fingerprint, firstUploadUrl]]) + const previousUploadCount = storedUploads.size + + const storedUploadUrl = + storedUploads.get(resume.fingerprint) ?? fail('stored upload URL is unavailable') + const assemblySslUrl = requireString( + createResponse.assembly_ssl_url, + 'createResponse.assembly_ssl_url', + ) + const completedAssembly = await client.resumeTusUpload(storedUploadUrl, content, assemblySslUrl) + if (completedAssembly.error) { + fail(`resumeTusUpload returned ${completedAssembly.error}: ${completedAssembly.message ?? ''}`) + } + + if (resume.removeFingerprintOnSuccess) { + storedUploads.delete(resume.fingerprint) + } + const remainingPreviousUploadCount = storedUploads.size + + await writeResult({ + firstUploadUrl, + previousUploadCount, + remainingPreviousUploadCount, + uploadUrl: firstUploadUrl, + }) + + console.log(`Node SDK devdock scenario ${scenarioId} resumed ${firstUploadUrl}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 137600ee..5367bc1a 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -656,6 +656,90 @@ export class Transloadit { // + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Resumes an interrupted TUS upload from the server-reported offset and waits for the Assembly to finish. + */ + async resumeTusUpload( + uploadUrl: string, + content: Buffer | Uint8Array | string, + assemblySslUrl: string, + ): Promise { + const storedUploadUrl = uploadUrl + if (!storedUploadUrl) { + throw new Error('TUS resumeUpload needs input.storedUploadUrl') + } + + const offsetHeaders: Record = {} + offsetHeaders['Tus-Resumable'] = '1.0.0' + const offsetResponse = await got(storedUploadUrl, { + method: 'HEAD', + headers: offsetHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (offsetResponse.statusCode !== 200) { + throw new Error(`TUS offset returned HTTP ${offsetResponse.statusCode}, expected 200`) + } + const resumeOffsetHeader = offsetResponse.headers['upload-offset'] + const resumeOffsetHeaderText = Array.isArray(resumeOffsetHeader) + ? resumeOffsetHeader[0] + : resumeOffsetHeader + if (!resumeOffsetHeaderText) { + throw new Error('TUS offset did not return a Upload-Offset header') + } + const resumeOffset = Number(resumeOffsetHeaderText) + if (!Number.isInteger(resumeOffset)) { + throw new Error('TUS offset returned an invalid Upload-Offset header') + } + + const contentBytes = Buffer.isBuffer(content) ? content : Buffer.from(content) + + const uploadHeaders: Record = {} + uploadHeaders['Tus-Resumable'] = '1.0.0' + uploadHeaders['Upload-Offset'] = String(resumeOffset) + uploadHeaders['Content-Type'] = 'application/offset+octet-stream' + const uploadResponse = await got(storedUploadUrl, { + method: 'PATCH', + body: contentBytes.subarray(resumeOffset), + headers: uploadHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (uploadResponse.statusCode !== 204) { + throw new Error(`TUS upload returned HTTP ${uploadResponse.statusCode}, expected 204`) + } + const uploadOffsetHeader = uploadResponse.headers['upload-offset'] + const uploadOffsetText = Array.isArray(uploadOffsetHeader) + ? uploadOffsetHeader[0] + : uploadOffsetHeader + if (!uploadOffsetText) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + const uploadOffset = Number(uploadOffsetText) + if (!Number.isInteger(uploadOffset)) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + if (uploadOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) + } + + const completedAssembly = await this.waitForAssembly(assemblySslUrl) + + return completedAssembly + } + + // + // // This block is generated from Transloadit API2 contracts. If it looks wrong, From 405dd11a0706443286a9c3c2e492cb34e7db22a0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 09:18:49 +0200 Subject: [PATCH 17/17] Poll Assembly list until the created Assembly lands The API acknowledges Assembly creation before the list storage row is inserted (the SQL save is fire-and-forget at identification time), so an immediate list can miss the just-created Assembly. Devdock logs show the insert completing 126ms after the list query ran. Poll briefly so the lifecycle proof asserts list contents deterministically instead of racing the insert. Co-Authored-By: Claude Fable 5 --- .../api2-devdock-assembly-lifecycle/main.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts index 7d8f1633..8e65f487 100644 --- a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -108,10 +108,22 @@ async function main(): Promise { try { const fetched = await client.getAssembly(assemblyId) - const listed = await client.listAssemblies({ + // The Assembly list is eventually consistent: the API acknowledges creation before the + // list storage row lands, so poll briefly until the created Assembly shows up. + let listed = await client.listAssemblies({ assembly_id: assemblyId, pagesize: scenario.list.pageSize, }) + for (let attempt = 0; attempt < 20; attempt += 1) { + if (listed.items.some((assembly) => assembly.id === assemblyId)) { + break + } + await new Promise((resolve) => setTimeout(resolve, 500)) + listed = await client.listAssemblies({ + assembly_id: assemblyId, + pagesize: scenario.list.pageSize, + }) + } const cancelled = await client.cancelAssembly(assemblyId) cancelOnExit = false