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" 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..8e65f487 --- /dev/null +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -0,0 +1,149 @@ +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) + // 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 + + 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) +}) 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) +}) 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..25c7d90e --- /dev/null +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -0,0 +1,174 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +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) +} + +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 stringRecord(value: unknown, label: string): Record { + const record = requireRecord(value, label) + return Object.fromEntries( + Object.entries(record).map(([key, entryValue]) => [key, String(entryValue)]), + ) +} + +function optionalStringRecord(value: unknown, label: string): Record { + if (value == null) { + return {} + } + + return stringRecord(value, label) +} + +function uploadConfig(value: unknown, label: string): UploadConfig { + const config = requireRecord(value, label) + + return { + content: requireString(config.content, `${label}.content`), + fieldname: requireString(config.fieldname, `${label}.fieldname`), + filename: requireString(config.filename, `${label}.filename`), + user_meta: optionalStringRecord(config.user_meta, `${label}.user_meta`), + } +} + +function uploadTusAssemblyInput(value: unknown, label: string): UploadTusAssemblyInput { + const input = requireRecord(value, label) + + return { + file_count: requireNumber(input.file_count, `${label}.file_count`), + upload: uploadConfig(input.upload, `${label}.upload`), + } +} + +function exampleInput(value: unknown, label: string): ExampleInput { + const input = requireRecord(value, label) + const sdkFeatureInputs = requireRecord(input.sdkFeatureInputs, `${label}.sdkFeatureInputs`) + + return { + scenarioId: requireString(input.scenarioId, `${label}.scenarioId`), + sdkFeatureInputs: { + uploadTusAssembly: uploadTusAssemblyInput( + sdkFeatureInputs.uploadTusAssembly, + `${label}.sdkFeatureInputs.uploadTusAssembly`, + ), + }, + } +} + +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 { + exampleInput: exampleInput(scenario.exampleInput, 'scenario.exampleInput'), + } +} + +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 input = scenario.exampleInput.sdkFeatureInputs.uploadTusAssembly + const upload = input.upload + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const result = await client.uploadTusAssembly( + input.file_count, + Buffer.from(upload.content, 'utf8'), + upload.fieldname, + upload.filename, + upload.user_meta, + ) + + await writeResult({ + createResponse: result.assembly, + uploadUrl: result.uploadUrl, + waitOk: result.assembly.ok, + }) + + console.log( + `Node SDK devdock scenario ${scenario.exampleInput.scenarioId} uploaded to ${result.uploadUrl}`, + ) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) 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 18ad3ef8..5367bc1a 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -108,6 +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 @@ -575,6 +588,263 @@ 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. + + /** + * 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, + 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. + + /** + * 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({ + 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)) + } + } + + // + + // + + // 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, + // 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, + 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_ssl_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 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 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 } + } + + // + async resumeAssemblyUploads( opts: ResumeAssemblyUploadsOptions, ): Promise { @@ -968,6 +1238,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 { @@ -978,6 +1254,8 @@ export class Transloadit { }) } + // + /** * Edit a Credential * @@ -985,6 +1263,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, @@ -996,12 +1280,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}`, @@ -1009,12 +1301,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}`, @@ -1022,12 +1322,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 { @@ -1038,6 +1346,8 @@ export class Transloadit { }) } + // + streamTemplateCredentials(params: ListTemplateCredentialsParams) { return new PaginationStream(async (page) => ({ items: (await this.listTemplateCredentials({ ...params, page })).credentials, @@ -1050,6 +1360,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', @@ -1058,6 +1374,8 @@ export class Transloadit { }) } + // + /** * Edit an Assembly Template * @@ -1065,6 +1383,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}`, @@ -1073,12 +1397,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}`, @@ -1086,12 +1418,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}`, @@ -1099,12 +1439,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> { @@ -1115,6 +1463,8 @@ export class Transloadit { }) } + // + streamTemplates(params?: ListTemplatesParams): PaginationStream { return new PaginationStream(async (page) => this.listTemplates({ ...params, page })) } @@ -1126,6 +1476,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({ @@ -1134,6 +1490,8 @@ export class Transloadit { }) } + // + calcSignature( params: OptionalAuthParams, algorithm?: string, 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'