@transloadit/node 4.3.0 → 4.4.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.
@@ -0,0 +1,278 @@
1
+ import { createWriteStream } from 'node:fs'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { isIP } from 'node:net'
4
+ import { tmpdir } from 'node:os'
5
+ import { basename, join } from 'node:path'
6
+ import type { Readable } from 'node:stream'
7
+ import { pipeline } from 'node:stream/promises'
8
+ import got from 'got'
9
+ import type { Input as IntoStreamInput } from 'into-stream'
10
+ import type { CreateAssemblyParams } from './apiTypes.ts'
11
+
12
+ export type InputFile =
13
+ | {
14
+ kind: 'path'
15
+ field: string
16
+ path: string
17
+ }
18
+ | {
19
+ kind: 'base64'
20
+ field: string
21
+ base64: string
22
+ filename: string
23
+ contentType?: string
24
+ }
25
+ | {
26
+ kind: 'url'
27
+ field: string
28
+ url: string
29
+ filename?: string
30
+ contentType?: string
31
+ }
32
+
33
+ export type UploadInput = Readable | IntoStreamInput
34
+
35
+ export type Base64Strategy = 'buffer' | 'tempfile'
36
+ export type UrlStrategy = 'import' | 'download' | 'import-if-present'
37
+
38
+ export type PrepareInputFilesOptions = {
39
+ inputFiles?: InputFile[]
40
+ params?: CreateAssemblyParams
41
+ fields?: Record<string, unknown>
42
+ base64Strategy?: Base64Strategy
43
+ urlStrategy?: UrlStrategy
44
+ maxBase64Bytes?: number
45
+ allowPrivateUrls?: boolean
46
+ tempDir?: string
47
+ }
48
+
49
+ export type PrepareInputFilesResult = {
50
+ params: CreateAssemblyParams
51
+ files: Record<string, string>
52
+ uploads: Record<string, UploadInput>
53
+ cleanup: Array<() => Promise<void>>
54
+ }
55
+
56
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
57
+ typeof value === 'object' && value !== null
58
+
59
+ const ensureUnique = (field: string, used: Set<string>): void => {
60
+ if (used.has(field)) {
61
+ throw new Error(`Duplicate file field: ${field}`)
62
+ }
63
+ used.add(field)
64
+ }
65
+
66
+ const ensureUniqueStepName = (baseName: string, used: Set<string>): string => {
67
+ let name = baseName
68
+ let counter = 1
69
+ while (used.has(name)) {
70
+ name = `${baseName}_${counter}`
71
+ counter += 1
72
+ }
73
+ used.add(name)
74
+ return name
75
+ }
76
+
77
+ const decodeBase64 = (value: string): Buffer => Buffer.from(value, 'base64')
78
+
79
+ const estimateBase64DecodedBytes = (value: string): number => {
80
+ const trimmed = value.trim()
81
+ if (!trimmed) return 0
82
+ let padding = 0
83
+ if (trimmed.endsWith('==')) padding = 2
84
+ else if (trimmed.endsWith('=')) padding = 1
85
+ return Math.floor((trimmed.length * 3) / 4) - padding
86
+ }
87
+
88
+ const getFilenameFromUrl = (value: string): string | null => {
89
+ try {
90
+ const pathname = new URL(value).pathname
91
+ const base = basename(pathname)
92
+ if (base && base !== '/' && base !== '.') return base
93
+ } catch {
94
+ return null
95
+ }
96
+ return null
97
+ }
98
+
99
+ const isHttpImportStep = (value: unknown): value is Record<string, unknown> =>
100
+ isRecord(value) && value.robot === '/http/import'
101
+
102
+ const findImportStepName = (field: string, steps: Record<string, unknown>): string | null => {
103
+ if (isHttpImportStep(steps[field])) return field
104
+ const matches = Object.entries(steps).filter(([, step]) => isHttpImportStep(step))
105
+ if (matches.length === 1) return matches[0]?.[0] ?? null
106
+ return null
107
+ }
108
+
109
+ const downloadUrlToFile = async (url: string, filePath: string): Promise<void> => {
110
+ await pipeline(got.stream(url), createWriteStream(filePath))
111
+ }
112
+
113
+ const isPrivateIp = (address: string): boolean => {
114
+ if (address === 'localhost') return true
115
+ const family = isIP(address)
116
+ if (family === 4) {
117
+ const parts = address.split('.').map((chunk) => Number(chunk))
118
+ const [a, b] = parts
119
+ if (a === 10) return true
120
+ if (a === 127) return true
121
+ if (a === 0) return true
122
+ if (a === 169 && b === 254) return true
123
+ if (a === 172 && b >= 16 && b <= 31) return true
124
+ if (a === 192 && b === 168) return true
125
+ return false
126
+ }
127
+ if (family === 6) {
128
+ const normalized = address.toLowerCase()
129
+ if (normalized === '::1') return true
130
+ if (normalized.startsWith('fe80:')) return true
131
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true
132
+ return false
133
+ }
134
+ return false
135
+ }
136
+
137
+ const assertPublicDownloadUrl = (value: string): void => {
138
+ const parsed = new URL(value)
139
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
140
+ throw new Error(`URL downloads are limited to http/https: ${value}`)
141
+ }
142
+ if (isPrivateIp(parsed.hostname)) {
143
+ throw new Error(`URL downloads are limited to public hosts: ${value}`)
144
+ }
145
+ }
146
+
147
+ export const prepareInputFiles = async (
148
+ options: PrepareInputFilesOptions = {},
149
+ ): Promise<PrepareInputFilesResult> => {
150
+ const {
151
+ inputFiles = [],
152
+ params = {},
153
+ fields,
154
+ base64Strategy = 'buffer',
155
+ urlStrategy = 'import',
156
+ maxBase64Bytes,
157
+ allowPrivateUrls = true,
158
+ tempDir,
159
+ } = options
160
+
161
+ let nextParams: CreateAssemblyParams = { ...params }
162
+ const files: Record<string, string> = {}
163
+ const uploads: Record<string, UploadInput> = {}
164
+ const cleanup: Array<() => Promise<void>> = []
165
+
166
+ if (fields && Object.keys(fields).length > 0) {
167
+ nextParams = {
168
+ ...nextParams,
169
+ fields: {
170
+ ...(isRecord(nextParams.fields) ? nextParams.fields : {}),
171
+ ...fields,
172
+ },
173
+ }
174
+ }
175
+
176
+ const steps = isRecord(nextParams.steps) ? { ...nextParams.steps } : {}
177
+ const usedSteps = new Set(Object.keys(steps))
178
+ const usedFields = new Set<string>()
179
+ const importUrlsByStep = new Map<string, string[]>()
180
+ const importStepNames = Object.keys(steps).filter((name) => isHttpImportStep(steps[name]))
181
+ const sharedImportStep = importStepNames.length === 1 ? importStepNames[0] : null
182
+
183
+ let tempRoot: string | null = null
184
+ const ensureTempRoot = async (): Promise<string> => {
185
+ if (!tempRoot) {
186
+ const root = await mkdtemp(join(tempDir ?? tmpdir(), 'transloadit-input-'))
187
+ tempRoot = root
188
+ cleanup.push(() => rm(root, { recursive: true, force: true }))
189
+ }
190
+ return tempRoot
191
+ }
192
+
193
+ try {
194
+ for (const file of inputFiles) {
195
+ ensureUnique(file.field, usedFields)
196
+ if (file.kind === 'path') {
197
+ files[file.field] = file.path
198
+ continue
199
+ }
200
+ if (file.kind === 'base64') {
201
+ if (maxBase64Bytes) {
202
+ const estimated = estimateBase64DecodedBytes(file.base64)
203
+ if (estimated > maxBase64Bytes) {
204
+ throw new Error(`Base64 payload exceeds ${maxBase64Bytes} bytes.`)
205
+ }
206
+ }
207
+ const buffer = decodeBase64(file.base64)
208
+ if (maxBase64Bytes && buffer.length > maxBase64Bytes) {
209
+ throw new Error(`Base64 payload exceeds ${maxBase64Bytes} bytes.`)
210
+ }
211
+ if (base64Strategy === 'tempfile') {
212
+ const root = await ensureTempRoot()
213
+ const filename = file.filename ? basename(file.filename) : `${file.field}.bin`
214
+ const filePath = join(root, filename)
215
+ await writeFile(filePath, buffer)
216
+ files[file.field] = filePath
217
+ } else {
218
+ uploads[file.field] = buffer
219
+ }
220
+ continue
221
+ }
222
+ if (file.kind === 'url') {
223
+ const matchedStep = findImportStepName(file.field, steps)
224
+ const targetStep = matchedStep ?? sharedImportStep
225
+ const shouldImport =
226
+ urlStrategy === 'import' || (urlStrategy === 'import-if-present' && targetStep)
227
+
228
+ if (shouldImport) {
229
+ const stepName = targetStep ?? ensureUniqueStepName(file.field, usedSteps)
230
+ const urls = importUrlsByStep.get(stepName) ?? []
231
+ urls.push(file.url)
232
+ importUrlsByStep.set(stepName, urls)
233
+ continue
234
+ }
235
+
236
+ const root = await ensureTempRoot()
237
+ const filename =
238
+ (file.filename ? basename(file.filename) : null) ??
239
+ getFilenameFromUrl(file.url) ??
240
+ `${file.field}.bin`
241
+ const filePath = join(root, filename)
242
+ if (!allowPrivateUrls) {
243
+ assertPublicDownloadUrl(file.url)
244
+ }
245
+ await downloadUrlToFile(file.url, filePath)
246
+ files[file.field] = filePath
247
+ }
248
+ }
249
+ } catch (error) {
250
+ await Promise.all(cleanup.map((fn) => fn()))
251
+ throw error
252
+ }
253
+
254
+ if (Object.keys(steps).length > 0 || importUrlsByStep.size > 0) {
255
+ if (importUrlsByStep.size > 0) {
256
+ for (const [stepName, urls] of importUrlsByStep.entries()) {
257
+ const existing = isRecord(steps[stepName]) ? steps[stepName] : {}
258
+ steps[stepName] = {
259
+ ...existing,
260
+ robot: '/http/import',
261
+ url: urls.length === 1 ? urls[0] : urls,
262
+ }
263
+ }
264
+ }
265
+
266
+ nextParams = {
267
+ ...nextParams,
268
+ steps,
269
+ }
270
+ }
271
+
272
+ return {
273
+ params: nextParams,
274
+ files,
275
+ uploads,
276
+ cleanup,
277
+ }
278
+ }
package/src/robots.ts ADDED
@@ -0,0 +1,317 @@
1
+ import type { z } from 'zod'
2
+ import { robotsMeta, robotsSchema } from './alphalib/types/robots/_index.ts'
3
+
4
+ export type RobotListOptions = {
5
+ category?: string
6
+ search?: string
7
+ limit?: number
8
+ cursor?: string
9
+ }
10
+
11
+ export type RobotListItem = {
12
+ name: string
13
+ title?: string
14
+ summary: string
15
+ category?: string
16
+ }
17
+
18
+ export type RobotListResult = {
19
+ robots: RobotListItem[]
20
+ nextCursor?: string
21
+ }
22
+
23
+ export type RobotParamHelp = {
24
+ name: string
25
+ type: string
26
+ description?: string
27
+ }
28
+
29
+ export type RobotHelp = {
30
+ name: string
31
+ summary: string
32
+ requiredParams: RobotParamHelp[]
33
+ optionalParams: RobotParamHelp[]
34
+ examples?: Array<{ description: string; snippet: Record<string, unknown> }>
35
+ }
36
+
37
+ export type RobotHelpOptions = {
38
+ robotName: string
39
+ detailLevel?: 'summary' | 'params' | 'examples'
40
+ }
41
+
42
+ type RobotsMetaMap = typeof robotsMeta
43
+ type RobotMeta = RobotsMetaMap[keyof RobotsMetaMap]
44
+
45
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
46
+ typeof value === 'object' && value !== null
47
+
48
+ const getDef = (schema: z.ZodTypeAny): Record<string, unknown> =>
49
+ (schema as unknown as { _def?: Record<string, unknown>; def?: Record<string, unknown> })._def ??
50
+ (schema as unknown as { def?: Record<string, unknown> }).def ??
51
+ {}
52
+
53
+ const getDefType = (def: Record<string, unknown>): string | undefined =>
54
+ (def.type as string | undefined) ?? (def.typeName as string | undefined)
55
+
56
+ const robotNameToPath = (name: string): string => {
57
+ const base = name.replace(/Robot$/, '')
58
+ const spaced = base
59
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
60
+ .replace(/([A-Z]+)([A-Z][a-z0-9])/g, '$1 $2')
61
+ const parts = spaced.split(/\s+/).filter(Boolean)
62
+ return `/${parts.map((part) => part.toLowerCase()).join('/')}`
63
+ }
64
+
65
+ const selectSummary = (meta: RobotMeta): string =>
66
+ meta.purpose_sentence ?? meta.purpose_words ?? meta.purpose_word ?? meta.title ?? meta.name
67
+
68
+ const resolveRobotPath = (robotName: string): string =>
69
+ robotName.startsWith('/') ? robotName : robotNameToPath(robotName)
70
+
71
+ const unwrapSchema = (schema: z.ZodTypeAny): { base: z.ZodTypeAny; optional: boolean } => {
72
+ let base = schema
73
+ let optional = typeof base.isOptional === 'function' ? base.isOptional() : false
74
+
75
+ while (true) {
76
+ const def = getDef(base)
77
+ const defType = getDefType(def)
78
+ if (
79
+ defType === 'optional' ||
80
+ defType === 'default' ||
81
+ defType === 'nullable' ||
82
+ defType === 'catch' ||
83
+ defType === 'ZodOptional' ||
84
+ defType === 'ZodDefault' ||
85
+ defType === 'ZodNullable' ||
86
+ defType === 'ZodCatch'
87
+ ) {
88
+ const inner = def.innerType as z.ZodTypeAny | undefined
89
+ if (inner) {
90
+ base = inner
91
+ if (defType !== 'nullable' && defType !== 'ZodNullable') {
92
+ optional = true
93
+ }
94
+ continue
95
+ }
96
+ }
97
+ break
98
+ }
99
+
100
+ return { base, optional }
101
+ }
102
+
103
+ const describeSchemaType = (schema: z.ZodTypeAny): string => {
104
+ const { base } = unwrapSchema(schema)
105
+ const def = getDef(base)
106
+ const defType = getDefType(def)
107
+
108
+ switch (defType) {
109
+ case 'string':
110
+ case 'ZodString':
111
+ return 'string'
112
+ case 'number':
113
+ case 'ZodNumber':
114
+ return 'number'
115
+ case 'boolean':
116
+ case 'ZodBoolean':
117
+ return 'boolean'
118
+ case 'bigint':
119
+ case 'ZodBigInt':
120
+ return 'bigint'
121
+ case 'literal':
122
+ case 'ZodLiteral': {
123
+ const value = (def.values as unknown[] | undefined)?.[0] ?? def.value
124
+ return value === undefined ? 'literal' : JSON.stringify(value)
125
+ }
126
+ case 'enum':
127
+ case 'ZodEnum': {
128
+ const values = Array.isArray(def.values) ? def.values : []
129
+ return values.length ? `enum(${values.join(' | ')})` : 'enum'
130
+ }
131
+ case 'array':
132
+ case 'ZodArray': {
133
+ const element = def.element as z.ZodTypeAny | undefined
134
+ const inner = element ? describeSchemaType(element) : 'unknown'
135
+ return `array<${inner}>`
136
+ }
137
+ case 'object':
138
+ case 'ZodObject':
139
+ return 'object'
140
+ case 'record':
141
+ case 'ZodRecord':
142
+ return 'record'
143
+ case 'union':
144
+ case 'ZodUnion': {
145
+ const options = Array.isArray(def.options) ? def.options : []
146
+ const rendered = options
147
+ .map((option) => describeSchemaType(option as z.ZodTypeAny))
148
+ .join(' | ')
149
+ return rendered ? `union<${rendered}>` : 'union'
150
+ }
151
+ case 'ZodDiscriminatedUnion':
152
+ return 'object'
153
+ default:
154
+ return defType ?? 'unknown'
155
+ }
156
+ }
157
+
158
+ const getParamDescription = (schema: z.ZodTypeAny): string | undefined => {
159
+ if (schema.description?.trim()) {
160
+ return schema.description.trim()
161
+ }
162
+ const inner = unwrapSchema(schema).base
163
+ return inner.description?.trim()
164
+ }
165
+
166
+ const getShape = (schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> => {
167
+ const { base } = unwrapSchema(schema)
168
+ const def = getDef(base)
169
+ const shape = def.shape as
170
+ | Record<string, z.ZodTypeAny>
171
+ | (() => Record<string, z.ZodTypeAny>)
172
+ | undefined
173
+ if (typeof shape === 'function') {
174
+ return shape()
175
+ }
176
+ return shape ?? {}
177
+ }
178
+
179
+ const getRobotParams = (
180
+ schema: z.ZodTypeAny,
181
+ ): { required: RobotParamHelp[]; optional: RobotParamHelp[] } => {
182
+ const shape = getShape(schema)
183
+ const required: RobotParamHelp[] = []
184
+ const optional: RobotParamHelp[] = []
185
+
186
+ for (const [key, value] of Object.entries(shape)) {
187
+ if (key === 'robot') continue
188
+ const { optional: isOptional } = unwrapSchema(value)
189
+ const param: RobotParamHelp = {
190
+ name: key,
191
+ type: describeSchemaType(value),
192
+ description: getParamDescription(value),
193
+ }
194
+
195
+ if (isOptional) {
196
+ optional.push(param)
197
+ } else {
198
+ required.push(param)
199
+ }
200
+ }
201
+
202
+ return { required, optional }
203
+ }
204
+
205
+ const getRobotsMetaIndex = (): {
206
+ byName: Map<string, RobotMeta>
207
+ byPath: Map<string, RobotMeta>
208
+ } => {
209
+ const byName = new Map<string, RobotMeta>()
210
+ const byPath = new Map<string, RobotMeta>()
211
+
212
+ for (const meta of Object.values(robotsMeta)) {
213
+ byName.set(meta.name, meta)
214
+ byPath.set(robotNameToPath(meta.name), meta)
215
+ }
216
+
217
+ return { byName, byPath }
218
+ }
219
+
220
+ const getRobotSchemaIndex = (): Map<string, z.ZodTypeAny> => {
221
+ const index = new Map<string, z.ZodTypeAny>()
222
+ for (const option of robotsSchema.options) {
223
+ const shape = getShape(option)
224
+ const robotSchema = shape.robot
225
+ if (!robotSchema) continue
226
+ const robotDef = getDef(robotSchema)
227
+ const robotLiteral = (robotDef.values as unknown[] | undefined)?.[0] ?? robotDef.value
228
+ if (typeof robotLiteral === 'string') {
229
+ index.set(robotLiteral, option)
230
+ }
231
+ }
232
+ return index
233
+ }
234
+
235
+ let cachedMetaIndex: ReturnType<typeof getRobotsMetaIndex> | null = null
236
+ let cachedSchemaIndex: ReturnType<typeof getRobotSchemaIndex> | null = null
237
+
238
+ const getMetaIndex = (): ReturnType<typeof getRobotsMetaIndex> => {
239
+ if (!cachedMetaIndex) {
240
+ cachedMetaIndex = getRobotsMetaIndex()
241
+ }
242
+ return cachedMetaIndex
243
+ }
244
+
245
+ const getSchemaIndex = (): ReturnType<typeof getRobotSchemaIndex> => {
246
+ if (!cachedSchemaIndex) {
247
+ cachedSchemaIndex = getRobotSchemaIndex()
248
+ }
249
+ return cachedSchemaIndex
250
+ }
251
+
252
+ export const listRobots = (options: RobotListOptions = {}): RobotListResult => {
253
+ const normalizedSearch = options.search?.toLowerCase()
254
+ const normalizedCategory = options.category?.toLowerCase()
255
+ const { byPath } = getMetaIndex()
256
+
257
+ const allRobots: RobotListItem[] = Array.from(byPath.entries()).map(([path, meta]) => ({
258
+ name: path,
259
+ title: meta.title,
260
+ summary: selectSummary(meta),
261
+ category: meta.service_slug,
262
+ }))
263
+
264
+ const filtered = allRobots
265
+ .filter((robot) => {
266
+ if (normalizedCategory && robot.category?.toLowerCase() !== normalizedCategory) {
267
+ return false
268
+ }
269
+ if (!normalizedSearch) return true
270
+ const haystack = `${robot.name} ${robot.title ?? ''} ${robot.summary}`.toLowerCase()
271
+ return haystack.includes(normalizedSearch)
272
+ })
273
+ .sort((a, b) => a.name.localeCompare(b.name))
274
+
275
+ const start = options.cursor ? Number.parseInt(options.cursor, 10) : 0
276
+ const safeStart = Number.isFinite(start) && start > 0 ? start : 0
277
+ const safeLimit = options.limit && options.limit > 0 ? options.limit : 20
278
+ const page = filtered.slice(safeStart, safeStart + safeLimit)
279
+ const nextCursor =
280
+ safeStart + safeLimit < filtered.length ? String(safeStart + safeLimit) : undefined
281
+
282
+ return {
283
+ robots: page,
284
+ nextCursor,
285
+ }
286
+ }
287
+
288
+ export const getRobotHelp = (options: RobotHelpOptions): RobotHelp => {
289
+ const detailLevel = options.detailLevel ?? 'summary'
290
+ const { byPath, byName } = getMetaIndex()
291
+ const schemaIndex = getSchemaIndex()
292
+
293
+ const path = resolveRobotPath(options.robotName)
294
+ const meta = byPath.get(path) ?? byName.get(options.robotName) ?? null
295
+ const summary = meta ? selectSummary(meta) : `Robot ${path}`
296
+ const schema = schemaIndex.get(path)
297
+ const params = schema ? getRobotParams(schema) : { required: [], optional: [] }
298
+
299
+ const help: RobotHelp = {
300
+ name: path,
301
+ summary,
302
+ requiredParams: detailLevel === 'params' ? params.required : [],
303
+ optionalParams: detailLevel === 'params' ? params.optional : [],
304
+ }
305
+
306
+ if (detailLevel === 'examples' && meta?.example_code) {
307
+ const snippet = isRecord(meta.example_code) ? meta.example_code : {}
308
+ help.examples = [
309
+ {
310
+ description: meta.example_code_description ?? 'Example',
311
+ snippet,
312
+ },
313
+ ]
314
+ }
315
+
316
+ return help
317
+ }