@transloadit/node 6.3.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +30 -5
  2. package/dist/Transloadit.d.ts.map +1 -1
  3. package/dist/Transloadit.js +61 -5
  4. package/dist/Transloadit.js.map +1 -1
  5. package/dist/alphalib/types/builtinTemplates.d.ts +22 -3
  6. package/dist/alphalib/types/builtinTemplates.d.ts.map +1 -1
  7. package/dist/alphalib/types/builtinTemplates.js +39 -3
  8. package/dist/alphalib/types/builtinTemplates.js.map +1 -1
  9. package/dist/alphalib/types/template.d.ts +18 -0
  10. package/dist/alphalib/types/template.d.ts.map +1 -1
  11. package/dist/alphalib/types/template.js +19 -0
  12. package/dist/alphalib/types/template.js.map +1 -1
  13. package/dist/cli/OutputCtl.d.ts +1 -0
  14. package/dist/cli/OutputCtl.d.ts.map +1 -1
  15. package/dist/cli/OutputCtl.js +1 -1
  16. package/dist/cli/OutputCtl.js.map +1 -1
  17. package/dist/cli/commands/BaseCommand.d.ts +3 -2
  18. package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
  19. package/dist/cli/commands/BaseCommand.js +27 -3
  20. package/dist/cli/commands/BaseCommand.js.map +1 -1
  21. package/dist/cli/commands/assemblies.d.ts +21 -1
  22. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  23. package/dist/cli/commands/assemblies.js +584 -91
  24. package/dist/cli/commands/assemblies.js.map +1 -1
  25. package/dist/cli/commands/templates.d.ts +1 -1
  26. package/dist/cli/commands/templates.d.ts.map +1 -1
  27. package/dist/cli/commands/templates.js +57 -67
  28. package/dist/cli/commands/templates.js.map +1 -1
  29. package/dist/cli/errors.d.ts +8 -0
  30. package/dist/cli/errors.d.ts.map +1 -0
  31. package/dist/cli/errors.js +28 -0
  32. package/dist/cli/errors.js.map +1 -0
  33. package/dist/cli/helpers.d.ts.map +1 -1
  34. package/dist/cli/helpers.js +22 -0
  35. package/dist/cli/helpers.js.map +1 -1
  36. package/dist/cli/intentRuntime.d.ts +1 -0
  37. package/dist/cli/intentRuntime.d.ts.map +1 -1
  38. package/dist/cli/intentRuntime.js +9 -3
  39. package/dist/cli/intentRuntime.js.map +1 -1
  40. package/dist/cli/resultFiles.d.ts +1 -0
  41. package/dist/cli/resultFiles.d.ts.map +1 -1
  42. package/dist/cli/resultFiles.js.map +1 -1
  43. package/dist/cli/resultUrls.d.ts +2 -1
  44. package/dist/cli/resultUrls.d.ts.map +1 -1
  45. package/dist/cli/resultUrls.js +6 -3
  46. package/dist/cli/resultUrls.js.map +1 -1
  47. package/dist/cli/stepsInput.d.ts.map +1 -1
  48. package/dist/cli/stepsInput.js +17 -4
  49. package/dist/cli/stepsInput.js.map +1 -1
  50. package/dist/cli/types.d.ts +66 -0
  51. package/dist/cli/types.d.ts.map +1 -1
  52. package/dist/cli/types.js +29 -0
  53. package/dist/cli/types.js.map +1 -1
  54. package/dist/tus.d.ts +7 -2
  55. package/dist/tus.d.ts.map +1 -1
  56. package/dist/tus.js +58 -4
  57. package/dist/tus.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/Transloadit.ts +65 -4
  60. package/src/alphalib/types/builtinTemplates.ts +50 -3
  61. package/src/alphalib/types/template.ts +30 -0
  62. package/src/cli/OutputCtl.ts +2 -1
  63. package/src/cli/commands/BaseCommand.ts +29 -7
  64. package/src/cli/commands/assemblies.ts +775 -118
  65. package/src/cli/commands/templates.ts +93 -98
  66. package/src/cli/errors.ts +34 -0
  67. package/src/cli/helpers.ts +21 -0
  68. package/src/cli/intentRuntime.ts +10 -3
  69. package/src/cli/resultFiles.ts +1 -0
  70. package/src/cli/resultUrls.ts +11 -3
  71. package/src/cli/stepsInput.ts +19 -4
  72. package/src/cli/types.ts +35 -0
  73. package/src/tus.ts +72 -5
@@ -11,11 +11,13 @@ import { Command, Option } from 'clipanion'
11
11
  import rreaddir from 'recursive-readdir'
12
12
  import { z } from 'zod'
13
13
 
14
+ import { ApiError } from '../../ApiError.ts'
14
15
  import { tryCatch } from '../../alphalib/tryCatch.ts'
16
+ import { CliInputError } from '../errors.ts'
15
17
  import { createReadStream, formatAPIError, streamToBuffer } from '../helpers.ts'
16
18
  import { createCliStepsSchema, parseStepsInputJson } from '../stepsInput.ts'
17
19
  import ModifiedLookup from '../template-last-modified.ts'
18
- import { ensureError, isTransloaditAPIError } from '../types.ts'
20
+ import { isTransloaditAPIError } from '../types.ts'
19
21
  import { AuthenticatedCommand } from './BaseCommand.ts'
20
22
 
21
23
  const rreaddirAsync = promisify(rreaddir)
@@ -54,27 +56,29 @@ export interface TemplateSyncOptions {
54
56
  recursive?: boolean
55
57
  }
56
58
 
59
+ async function readTemplateInput<T>(input: Promise<T>): Promise<T> {
60
+ const [error, content] = await tryCatch(input)
61
+ if (error) {
62
+ throw new CliInputError(`Cannot read Template input: ${error.message}`, { cause: error })
63
+ }
64
+ return content
65
+ }
66
+
57
67
  export async function create(
58
68
  output: IOutputCtl,
59
69
  client: Transloadit,
60
70
  { name, file }: TemplateCreateOptions,
61
71
  ): Promise<unknown> {
62
- try {
63
- const buf = await streamToBuffer(createReadStream(file))
72
+ const buf = await readTemplateInput(streamToBuffer(createReadStream(file)))
64
73
 
65
- const steps = parseStepsInputJson(buf.toString(), output)
74
+ const steps = parseStepsInputJson(buf.toString(), output)
66
75
 
67
- const result = await client.createTemplate({
68
- name,
69
- template: { steps } as TemplateContent,
70
- })
71
- output.print(result.id, result)
72
- return result
73
- } catch (err) {
74
- const error = ensureError(err)
75
- output.error(error.message)
76
- throw err
77
- }
76
+ const result = await client.createTemplate({
77
+ name,
78
+ template: { steps } as TemplateContent,
79
+ })
80
+ output.print(result.id, result)
81
+ return result
78
82
  }
79
83
 
80
84
  export async function get(
@@ -84,11 +88,7 @@ export async function get(
84
88
  ): Promise<void> {
85
89
  const requests = templates.map((template) => client.getTemplate(template))
86
90
 
87
- const [err, results] = await tryCatch(Promise.all(requests))
88
- if (err) {
89
- output.error(formatAPIError(err))
90
- throw err
91
- }
91
+ const results = await Promise.all(requests)
92
92
 
93
93
  for (const result of results) {
94
94
  output.print(result, result)
@@ -100,53 +100,49 @@ export async function modify(
100
100
  client: Transloadit,
101
101
  { template, name, file }: TemplateModifyOptions,
102
102
  ): Promise<void> {
103
- try {
104
- const buf = await streamToBuffer(createReadStream(file))
103
+ const buf = await readTemplateInput(streamToBuffer(createReadStream(file)))
105
104
 
106
- let steps: TemplateContent['steps'] | null = null
107
- let newName = name
105
+ let steps: TemplateContent['steps'] | null = null
106
+ let newName = name
108
107
 
109
- if (buf.length > 0) {
110
- steps = parseStepsInputJson(buf.toString(), output) as TemplateContent['steps']
111
- }
112
-
113
- if (!name || buf.length === 0) {
114
- const tpl = await client.getTemplate(template)
115
- if (!name) newName = tpl.name
116
- if (buf.length === 0 && tpl.content.steps) {
117
- steps = tpl.content.steps as TemplateContent['steps']
118
- }
119
- }
108
+ if (buf.length > 0) {
109
+ steps = parseStepsInputJson(buf.toString(), output) as TemplateContent['steps']
110
+ }
120
111
 
121
- if (steps === null) {
122
- throw new Error('No steps to update template with')
112
+ if (!name || buf.length === 0) {
113
+ const tpl = await client.getTemplate(template)
114
+ if (!name) newName = tpl.name
115
+ if (buf.length === 0 && tpl.content.steps) {
116
+ steps = tpl.content.steps as TemplateContent['steps']
123
117
  }
118
+ }
124
119
 
125
- await client.editTemplate(template, {
126
- name: newName,
127
- // Steps (validated) is assignable to StepsInput at runtime; cast for TS
128
- template: { steps } as TemplateContent,
129
- })
130
- } catch (err) {
131
- output.error(formatAPIError(err))
132
- throw err
120
+ if (steps === null) {
121
+ throw new CliInputError('No steps to update template with')
133
122
  }
123
+
124
+ await client.editTemplate(template, {
125
+ name: newName,
126
+ // Steps (validated) is assignable to StepsInput at runtime; cast for TS
127
+ template: { steps } as TemplateContent,
128
+ })
134
129
  }
135
130
 
136
131
  async function deleteTemplates(
137
132
  output: IOutputCtl,
138
133
  client: Transloadit,
139
134
  { templates }: TemplateDeleteOptions,
140
- ): Promise<void> {
141
- await Promise.all(
142
- templates.map(async (template) => {
143
- const [err] = await tryCatch(client.deleteTemplate(template))
144
- if (err) {
145
- output.error(formatAPIError(err))
146
- throw err
147
- }
148
- }),
135
+ ): Promise<number | undefined> {
136
+ const results = await Promise.allSettled(
137
+ templates.map((template) => client.deleteTemplate(template)),
149
138
  )
139
+ let exitCode: number | undefined
140
+ for (const [index, result] of results.entries()) {
141
+ if (result.status === 'fulfilled') continue
142
+ output.error(`Template ${templates[index]}: ${formatAPIError(result.reason)}`)
143
+ exitCode = 1
144
+ }
145
+ return exitCode
150
146
  }
151
147
 
152
148
  // Export with `delete` alias for external consumers
@@ -281,54 +277,54 @@ export async function sync(
281
277
 
282
278
  const modified = new ModifiedLookup(client)
283
279
 
284
- const [err] = await tryCatch(
285
- Promise.all(
286
- templates.map(async (template) => {
287
- if (!('steps' in template.data)) {
288
- if (!template.data.transloadit_template_id) {
289
- throw new Error(`Template file has no id and no steps: ${template.file}`)
290
- }
291
- return download(template)
280
+ await Promise.all(
281
+ templates.map(async (template) => {
282
+ if (!('steps' in template.data)) {
283
+ if (!template.data.transloadit_template_id) {
284
+ throw new CliInputError(`Template file has no id and no steps: ${template.file}`)
292
285
  }
286
+ return download(template)
287
+ }
293
288
 
294
- if (!template.data.transloadit_template_id) return upload(template)
295
-
296
- const stats = await fsp.stat(template.file)
297
- const fileModified = stats.mtime
298
-
299
- let templateModified: Date
300
- const templateId = template.data.transloadit_template_id
301
- try {
302
- await client.getTemplate(templateId)
303
- templateModified = await new Promise<Date>((resolve, reject) =>
304
- modified.byId(templateId, (err, res) => {
305
- if (err) {
306
- reject(err)
307
- } else if (res) {
308
- resolve(res)
309
- } else {
310
- reject(new Error('No date returned'))
311
- }
312
- }),
313
- )
314
- } catch (err) {
315
- if (isTransloaditAPIError(err)) {
316
- if (err.code === 'SERVER_404' || (err.response && err.response.statusCode === 404)) {
317
- throw new Error(`Template file references nonexistent template: ${template.file}`)
289
+ if (!template.data.transloadit_template_id) return upload(template)
290
+
291
+ const stats = await readTemplateInput(fsp.stat(template.file))
292
+ const fileModified = stats.mtime
293
+
294
+ let templateModified: Date
295
+ const templateId = template.data.transloadit_template_id
296
+ try {
297
+ await client.getTemplate(templateId)
298
+ templateModified = await new Promise<Date>((resolve, reject) =>
299
+ modified.byId(templateId, (err, res) => {
300
+ if (err) {
301
+ reject(err)
302
+ } else if (res) {
303
+ resolve(res)
304
+ } else {
305
+ reject(new Error('No date returned'))
318
306
  }
319
- }
320
- throw err
307
+ }),
308
+ )
309
+ } catch (err) {
310
+ if (
311
+ (err instanceof ApiError &&
312
+ (err.code === 'TEMPLATE_NOT_FOUND' || err.cause?.response?.statusCode === 404)) ||
313
+ (isTransloaditAPIError(err) &&
314
+ (err.code === 'SERVER_404' || err.response?.statusCode === 404))
315
+ ) {
316
+ throw new CliInputError(
317
+ `Template file references nonexistent template: ${template.file}`,
318
+ { cause: err },
319
+ )
321
320
  }
321
+ throw err
322
+ }
322
323
 
323
- if (fileModified > templateModified) return upload(template)
324
- return download(template)
325
- }),
326
- ),
324
+ if (fileModified > templateModified) return upload(template)
325
+ return download(template)
326
+ }),
327
327
  )
328
- if (err) {
329
- output.error(err)
330
- throw err
331
- }
332
328
 
333
329
  async function upload(template: TemplateFile): Promise<void> {
334
330
  const params = {
@@ -479,10 +475,9 @@ export class TemplatesDeleteCommand extends AuthenticatedCommand {
479
475
  templateIds = Option.Rest({ required: 1 })
480
476
 
481
477
  protected async run(): Promise<number | undefined> {
482
- await deleteTemplates(this.output, this.client, {
478
+ return await deleteTemplates(this.output, this.client, {
483
479
  templates: this.templateIds,
484
480
  })
485
- return undefined
486
481
  }
487
482
  }
488
483
 
@@ -0,0 +1,34 @@
1
+ import type { ZodIssue } from 'zod'
2
+
3
+ /** An input error the CLI can explain without an internal-error stack trace. */
4
+ export class CliInputError extends Error {
5
+ override name = 'CliInputError'
6
+ }
7
+
8
+ function describeIssues(issues: ZodIssue[]): string[] {
9
+ return issues.flatMap((issue) => {
10
+ if (issue.code === 'invalid_union') {
11
+ const descriptions = issue.unionErrors.flatMap((error) => describeIssues(error.issues))
12
+ // Interpolation is an alternative to literal Robot parameters, not a required input format.
13
+ const literals = descriptions.filter(
14
+ (description) => !description.endsWith(': Must be a full interpolation string'),
15
+ )
16
+ return literals.length > 0 ? literals : descriptions
17
+ }
18
+ const message =
19
+ issue.code === 'invalid_enum_value'
20
+ ? `Expected one of: ${issue.options.join(', ')}`
21
+ : issue.message
22
+ return [`${issue.path.join('.') || '(root)'}: ${message}`]
23
+ })
24
+ }
25
+
26
+ /** Summarize validation paths and remedies without duplicating Zod's union diagnostics. */
27
+ export function formatSchemaIssues(issues: ZodIssue[]): string {
28
+ const descriptions = [...new Set(describeIssues(issues))]
29
+ const remaining = descriptions.length - 10
30
+ return [
31
+ ...descriptions.slice(0, 10),
32
+ ...(remaining > 0 ? [`${remaining} more validation error${remaining === 1 ? '' : 's'}`] : []),
33
+ ].join('; ')
34
+ }
@@ -8,8 +8,10 @@ import { homedir, userInfo } from 'node:os'
8
8
  import path from 'node:path'
9
9
 
10
10
  import { parse as parseDotenv } from 'dotenv'
11
+ import { ParseError, RequestError } from 'got'
11
12
  import { z } from 'zod'
12
13
 
14
+ import { ApiError } from '../ApiError.ts'
13
15
  import { isAPIError } from './types.ts'
14
16
 
15
17
  /** API signing algorithms supported by CLI credentials and device authorization. */
@@ -535,6 +537,25 @@ export async function readCliInput(options: ReadCliInputOptions): Promise<CliInp
535
537
  }
536
538
 
537
539
  export function formatAPIError(err: unknown): string {
540
+ if (err instanceof RequestError) {
541
+ // ParseError messages contain response fragments and signed request URLs.
542
+ if (err instanceof ParseError) return `${err.code}: The API returned invalid JSON.`
543
+ const requestUrl = err.options.url
544
+ const message =
545
+ requestUrl instanceof URL
546
+ ? err.message.replaceAll(requestUrl.href, `${requestUrl.origin}${requestUrl.pathname}`)
547
+ : err.message
548
+ return `${err.code}: ${message}`
549
+ }
550
+ if (err instanceof ApiError && !err.assemblySslUrl) {
551
+ const requestUrl = err.cause?.options.url
552
+ const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl
553
+ if (url && /\/assemblies\/[^/]+$/.test(url.pathname)) {
554
+ // Request URLs can carry signed query parameters; only expose the Assembly location.
555
+ return `${err.message} (Assembly URL: ${url.origin}${url.pathname})`
556
+ }
557
+ if (err.assemblyId) return `${err.message} (Assembly ID: ${err.assemblyId})`
558
+ }
538
559
  if (isAPIError(err)) {
539
560
  return `${err.error}: ${err.message}`
540
561
  }
@@ -33,6 +33,7 @@ export interface PreparedIntentInputs {
33
33
  cleanup: Array<() => Promise<void>>
34
34
  hasTransientInputs: boolean
35
35
  inputs: string[]
36
+ transientInputPaths?: string[]
36
37
  }
37
38
 
38
39
  export interface IntentSingleStepExecutionDefinition {
@@ -252,6 +253,7 @@ export async function prepareIntentInputs({
252
253
  cleanup: prepared.cleanup,
253
254
  hasTransientInputs: true,
254
255
  inputs,
256
+ transientInputPaths: Object.values(prepared.files),
255
257
  }
256
258
  }
257
259
 
@@ -383,14 +385,15 @@ async function executeIntentCommand({
383
385
  } as AssembliesCreateOptions['stepsData'],
384
386
  }
385
387
 
386
- const { hasFailures, resultUrls } = await assembliesCommands.create(output, client, {
388
+ const { assemblies, hasFailures, resultUrls } = await assembliesCommands.create(output, client, {
387
389
  ...createOptions,
388
390
  output: outputPath ?? null,
389
391
  outputMode,
390
392
  ...executionOptions,
393
+ printUrls,
391
394
  })
392
395
  if (printUrls) {
393
- printResultUrls(output, resultUrls)
396
+ printResultUrls(output, resultUrls, assemblies)
394
397
  }
395
398
  return hasFailures ? 1 : undefined
396
399
  }
@@ -766,9 +769,13 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase
766
769
  }
767
770
 
768
771
  const effectiveOutputPath = this.getEffectiveOutputPath(rawValues)
772
+ const createOptions = this.getCreateOptions(effectivePreparedInputs.inputs)
773
+ if (effectivePreparedInputs.transientInputPaths != null) {
774
+ createOptions.transientInputPaths = effectivePreparedInputs.transientInputPaths
775
+ }
769
776
  return await executeIntentCommand({
770
777
  client: this.client,
771
- createOptions: this.getCreateOptions(effectivePreparedInputs.inputs),
778
+ createOptions,
772
779
  definition: this.getIntentDefinition(),
773
780
  output: this.output,
774
781
  outputMode: this.getEffectiveOutputMode(rawValues, effectiveOutputPath),
@@ -1,6 +1,7 @@
1
1
  export interface AssemblyResultEntryLike {
2
2
  basename?: unknown
3
3
  ext?: unknown
4
+ meta?: unknown
4
5
  name?: unknown
5
6
  ssl_url?: unknown
6
7
  url?: unknown
@@ -1,5 +1,6 @@
1
1
  import type { IOutputCtl } from './OutputCtl.ts'
2
2
  import type { NormalizedAssemblyResults } from './resultFiles.ts'
3
+ import type { AssemblySummary } from './types.ts'
3
4
 
4
5
  import { normalizeAssemblyResults } from './resultFiles.ts'
5
6
 
@@ -64,10 +65,17 @@ export function formatResultUrlRows(rows: readonly ResultUrlRow[]): string {
64
65
  .join('\n')
65
66
  }
66
67
 
67
- export function printResultUrls(output: IOutputCtl, rows: readonly ResultUrlRow[]): void {
68
- if (rows.length === 0) {
68
+ export function printResultUrls(
69
+ output: IOutputCtl,
70
+ rows: readonly ResultUrlRow[],
71
+ assemblies?: readonly AssemblySummary[],
72
+ ): void {
73
+ if (rows.length === 0 && !assemblies?.length) {
69
74
  return
70
75
  }
71
76
 
72
- output.print(formatResultUrlRows(rows), { urls: rows })
77
+ output.print(rows.length > 0 ? formatResultUrlRows(rows) : undefined, {
78
+ urls: rows,
79
+ ...(assemblies?.length ? { assemblies } : {}),
80
+ })
73
81
  }
@@ -5,9 +5,11 @@ import fsp from 'node:fs/promises'
5
5
 
6
6
  import { z } from 'zod'
7
7
 
8
+ import { tryCatch } from '../alphalib/tryCatch.ts'
8
9
  import { robotsSchema } from '../alphalib/types/robots/_index.ts'
9
10
  import { refineDocumentExtractOcr } from '../alphalib/types/robots/document-extract.ts'
10
11
  import { refineTransloaditImportSelector } from '../alphalib/types/robots/transloadit-import.ts'
12
+ import { CliInputError, formatSchemaIssues } from './errors.ts'
11
13
 
12
14
  const stepsWrapperSchema = z.object({ steps: z.record(z.unknown()) })
13
15
 
@@ -35,7 +37,12 @@ export function parseStepsInputJson(
35
37
  content: string,
36
38
  output: Pick<IOutputCtl, 'warn'> = console,
37
39
  ): StepsInput {
38
- const parsed: unknown = JSON.parse(content)
40
+ let parsed: unknown
41
+ try {
42
+ parsed = JSON.parse(content)
43
+ } catch (error) {
44
+ throw new CliInputError('Invalid steps format: expected valid JSON', { cause: error })
45
+ }
39
46
  const wrapped = stepsWrapperSchema.safeParse(parsed)
40
47
  let stepsInput = parsed
41
48
  // A step named “steps” has a robot, unlike the top-level instructions wrapper.
@@ -43,13 +50,18 @@ export function parseStepsInputJson(
43
50
  // --steps only supplies steps; other instructions must not be silently discarded.
44
51
  const validatedWrapper = stepsWrapperSchema.strict().safeParse(parsed)
45
52
  if (!validatedWrapper.success) {
46
- throw new Error(`Invalid steps format: ${validatedWrapper.error.message}`)
53
+ throw new CliInputError(
54
+ `Invalid steps format: ${formatSchemaIssues(validatedWrapper.error.issues)}`,
55
+ { cause: validatedWrapper.error },
56
+ )
47
57
  }
48
58
  stepsInput = validatedWrapper.data.steps
49
59
  }
50
60
  const validated = createCliStepsSchema(output).safeParse(stepsInput)
51
61
  if (!validated.success) {
52
- throw new Error(`Invalid steps format: ${validated.error.message}`)
62
+ throw new CliInputError(`Invalid steps format: ${formatSchemaIssues(validated.error.issues)}`, {
63
+ cause: validated.error,
64
+ })
53
65
  }
54
66
 
55
67
  const parsedSteps = stepsInput as Record<string, Record<string, unknown>>
@@ -72,6 +84,9 @@ export async function readStepsInputFile(
72
84
  filePath: string,
73
85
  output: Pick<IOutputCtl, 'warn'> = console,
74
86
  ): Promise<StepsInput> {
75
- const content = await fsp.readFile(filePath, 'utf8')
87
+ const [error, content] = await tryCatch(fsp.readFile(filePath, 'utf8'))
88
+ if (error) {
89
+ throw new CliInputError(`Cannot read steps input: ${error.message}`, { cause: error })
90
+ }
76
91
  return parseStepsInputJson(content, output)
77
92
  }
package/src/cli/types.ts CHANGED
@@ -2,6 +2,41 @@ import type { Steps } from '../alphalib/types/template.ts'
2
2
 
3
3
  import { z } from 'zod'
4
4
 
5
+ const ignoredErrorDetailsSchema = z
6
+ .union([
7
+ z.string(),
8
+ z.object({
9
+ code: z.string().optional().catch(undefined),
10
+ message: z.string().optional().catch(undefined),
11
+ }),
12
+ ])
13
+ .optional()
14
+ .catch(undefined)
15
+
16
+ const ignoredErrorSummarySchema = z
17
+ .object({
18
+ step: z.string().nullable().optional().catch(undefined),
19
+ phase: z.string().optional().catch(undefined),
20
+ error: ignoredErrorDetailsSchema,
21
+ message: z.string().optional().catch(undefined),
22
+ })
23
+ .catch({})
24
+
25
+ /** Project unchecked API responses to CLI diagnostics without nested request metadata. */
26
+ export const assemblySummarySchema = z.object({
27
+ assembly_id: z.string().optional().catch(undefined),
28
+ assembly_url: z.string().nullable().optional().catch(undefined),
29
+ assembly_ssl_url: z.string().nullable().optional().catch(undefined),
30
+ ok: z.string().nullable().optional().catch(undefined),
31
+ error: z.string().optional().catch(undefined),
32
+ message: z.string().optional().catch(undefined),
33
+ ignored_error_count: z.number().finite().int().nonnegative().default(0).catch(0),
34
+ ignored_errors: z.array(ignoredErrorSummarySchema).default([]).catch([]),
35
+ })
36
+
37
+ /** Public diagnostics from one Assembly, excluding instructions and request internals. */
38
+ export type AssemblySummary = z.infer<typeof assemblySummarySchema>
39
+
5
40
  // Zod schemas for runtime validation
6
41
  const APIErrorSchema = z.object({
7
42
  error: z.string(),
package/src/tus.ts CHANGED
@@ -1,16 +1,24 @@
1
1
  import type { Readable } from 'node:stream'
2
2
 
3
- import type { OnSuccessPayload, UploadOptions } from 'tus-js-client'
3
+ import type {
4
+ FileSource,
5
+ HttpRequest,
6
+ HttpResponse,
7
+ OnSuccessPayload,
8
+ SliceResult,
9
+ UploadOptions,
10
+ } from 'tus-js-client'
4
11
 
5
12
  import type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts'
6
13
  import type { UploadProgress } from './Transloadit.ts'
7
14
 
15
+ import { ReadStream } from 'node:fs'
8
16
  import { stat } from 'node:fs/promises'
9
17
  import { basename } from 'node:path'
10
18
 
11
19
  import debug from 'debug'
12
20
  import pMap from 'p-map'
13
- import { Upload } from 'tus-js-client'
21
+ import { defaultOptions, Upload } from 'tus-js-client'
14
22
 
15
23
  const log = debug('transloadit')
16
24
  const logWarn = debug('transloadit:warn')
@@ -31,6 +39,13 @@ interface SendTusRequestOptions {
31
39
  signal?: AbortSignal
32
40
  uploadUrls?: Record<string, string>
33
41
  uploadBehavior?: UploadBehavior
42
+ onFileStreamOpened?: (stream: ReadStream) => void
43
+ onFileSliceStarted?: (slice: Promise<unknown>) => void
44
+ }
45
+
46
+ interface SendTusRequestResult {
47
+ uploadUrls: Record<string, string>
48
+ uploadsComplete?: Promise<unknown>
34
49
  }
35
50
 
36
51
  export async function sendTusRequest({
@@ -42,7 +57,9 @@ export async function sendTusRequest({
42
57
  signal,
43
58
  uploadUrls,
44
59
  uploadBehavior = 'await',
45
- }: SendTusRequestOptions) {
60
+ onFileStreamOpened,
61
+ onFileSliceStarted,
62
+ }: SendTusRequestOptions): Promise<SendTusRequestResult> {
46
63
  const streamLabels = Object.keys(streamsMap)
47
64
 
48
65
  let totalBytes = 0
@@ -203,6 +220,25 @@ export async function sendTusRequest({
203
220
  fieldname: label,
204
221
  filename,
205
222
  },
223
+ httpStack: {
224
+ createRequest(method: string, url: string): HttpRequest {
225
+ const request = defaultOptions.httpStack.createRequest(method, url)
226
+ const send = request.send.bind(request)
227
+ request.send = (body: unknown): Promise<HttpResponse> => {
228
+ // tus awaits pre-request hooks, so queued aborts must be checked at send itself.
229
+ if (signal?.aborted) {
230
+ // File setup can finish after abort() and reset tus's aborted state.
231
+ tusUpload.abort()
232
+ return Promise.reject(new Error('Upload aborted'))
233
+ }
234
+ return send(body)
235
+ }
236
+ return request
237
+ },
238
+ getName(): string {
239
+ return defaultOptions.httpStack.getName()
240
+ },
241
+ },
206
242
  onError: reject,
207
243
  onProgress: onTusProgress,
208
244
  onSuccess: resolve,
@@ -218,6 +254,36 @@ export async function sendTusRequest({
218
254
  }
219
255
  },
220
256
  }
257
+ if (onFileStreamOpened) {
258
+ // tus opens a separate ReadStream for each chunk; abort() leaves those streams open.
259
+ // Track those chunks even when the original file stream belongs to the caller.
260
+ tusOptions.fileReader = {
261
+ async openFile(input: unknown, chunkSize: number): Promise<FileSource> {
262
+ const source = await defaultOptions.fileReader.openFile(input, chunkSize)
263
+ return {
264
+ size: source.size,
265
+ async slice(start: number, end: number): Promise<SliceResult> {
266
+ if (signal?.aborted) throw new Error('Upload aborted')
267
+ const reading = source.slice(start, end).then((chunk) => {
268
+ // tus-js-client declares platform-specific chunk values as any.
269
+ const value: unknown = chunk.value
270
+ if (value instanceof ReadStream) onFileStreamOpened(value)
271
+ if (signal?.aborted) throw new Error('Upload aborted')
272
+ return chunk
273
+ })
274
+ // A file slice can open its stream before the promise resolves.
275
+ if (input instanceof ReadStream && input.path != null) {
276
+ onFileSliceStarted?.(reading)
277
+ }
278
+ return await reading
279
+ },
280
+ close() {
281
+ source.close()
282
+ },
283
+ }
284
+ },
285
+ }
286
+ }
221
287
  // tus-js-client doesn't like undefined/null
222
288
  if (size != null) tusOptions.uploadSize = size
223
289
  if (chunkSize) tusOptions.chunkSize = chunkSize
@@ -255,13 +321,14 @@ export async function sendTusRequest({
255
321
 
256
322
  await Promise.all(uploadUrlPromises)
257
323
 
324
+ const uploadsComplete = Promise.allSettled(completionPromises)
258
325
  if (uploadBehavior === 'await') {
259
326
  await Promise.all(completionPromises)
260
327
  } else {
261
- Promise.allSettled(completionPromises).catch((err) => {
328
+ uploadsComplete.catch((err) => {
262
329
  logWarn('Background upload failed', err)
263
330
  })
264
331
  }
265
332
 
266
- return { uploadUrls: uploadUrlsResult }
333
+ return { uploadUrls: uploadUrlsResult, uploadsComplete }
267
334
  }