@transloadit/node 4.2.0 → 4.3.1
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.
- package/README.md +116 -4
- package/dist/Transloadit.d.ts +45 -4
- package/dist/Transloadit.d.ts.map +1 -1
- package/dist/Transloadit.js +104 -21
- package/dist/Transloadit.js.map +1 -1
- package/dist/alphalib/assembly-linter.d.ts +123 -0
- package/dist/alphalib/assembly-linter.d.ts.map +1 -0
- package/dist/alphalib/assembly-linter.js +1142 -0
- package/dist/alphalib/assembly-linter.js.map +1 -0
- package/dist/alphalib/assembly-linter.lang.en.d.ts +87 -0
- package/dist/alphalib/assembly-linter.lang.en.d.ts.map +1 -0
- package/dist/alphalib/assembly-linter.lang.en.js +326 -0
- package/dist/alphalib/assembly-linter.lang.en.js.map +1 -0
- package/dist/alphalib/goldenTemplates.d.ts +52 -0
- package/dist/alphalib/goldenTemplates.d.ts.map +1 -0
- package/dist/alphalib/goldenTemplates.js +46 -0
- package/dist/alphalib/goldenTemplates.js.map +1 -0
- package/dist/alphalib/object.d.ts +20 -0
- package/dist/alphalib/object.d.ts.map +1 -0
- package/dist/alphalib/object.js +23 -0
- package/dist/alphalib/object.js.map +1 -0
- package/dist/alphalib/stepParsing.d.ts +93 -0
- package/dist/alphalib/stepParsing.d.ts.map +1 -0
- package/dist/alphalib/stepParsing.js +1154 -0
- package/dist/alphalib/stepParsing.js.map +1 -0
- package/dist/alphalib/templateMerge.d.ts +4 -0
- package/dist/alphalib/templateMerge.d.ts.map +1 -0
- package/dist/alphalib/templateMerge.js +22 -0
- package/dist/alphalib/templateMerge.js.map +1 -0
- package/dist/cli/commands/assemblies.d.ts +20 -1
- package/dist/cli/commands/assemblies.d.ts.map +1 -1
- package/dist/cli/commands/assemblies.js +137 -2
- package/dist/cli/commands/assemblies.js.map +1 -1
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +19 -19
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/docs/assemblyLintingExamples.d.ts +2 -0
- package/dist/cli/docs/assemblyLintingExamples.d.ts.map +1 -0
- package/dist/cli/docs/assemblyLintingExamples.js +10 -0
- package/dist/cli/docs/assemblyLintingExamples.js.map +1 -0
- package/dist/cli/helpers.d.ts +11 -0
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/helpers.js +29 -0
- package/dist/cli/helpers.js.map +1 -1
- package/dist/inputFiles.d.ts +41 -0
- package/dist/inputFiles.d.ts.map +1 -0
- package/dist/inputFiles.js +214 -0
- package/dist/inputFiles.js.map +1 -0
- package/dist/lintAssemblyInput.d.ts +10 -0
- package/dist/lintAssemblyInput.d.ts.map +1 -0
- package/dist/lintAssemblyInput.js +73 -0
- package/dist/lintAssemblyInput.js.map +1 -0
- package/dist/lintAssemblyInstructions.d.ts +29 -0
- package/dist/lintAssemblyInstructions.d.ts.map +1 -0
- package/dist/lintAssemblyInstructions.js +33 -0
- package/dist/lintAssemblyInstructions.js.map +1 -0
- package/dist/robots.d.ts +38 -0
- package/dist/robots.d.ts.map +1 -0
- package/dist/robots.js +230 -0
- package/dist/robots.js.map +1 -0
- package/dist/tus.d.ts +5 -1
- package/dist/tus.d.ts.map +1 -1
- package/dist/tus.js +80 -6
- package/dist/tus.js.map +1 -1
- package/package.json +5 -2
- package/src/Transloadit.ts +170 -26
- package/src/alphalib/assembly-linter.lang.en.ts +393 -0
- package/src/alphalib/assembly-linter.ts +1475 -0
- package/src/alphalib/goldenTemplates.ts +53 -0
- package/src/alphalib/object.ts +27 -0
- package/src/alphalib/stepParsing.ts +1465 -0
- package/src/alphalib/templateMerge.ts +32 -0
- package/src/alphalib/typings/json-to-ast.d.ts +34 -0
- package/src/cli/commands/assemblies.ts +161 -2
- package/src/cli/commands/auth.ts +19 -22
- package/src/cli/commands/index.ts +2 -0
- package/src/cli/docs/assemblyLintingExamples.ts +9 -0
- package/src/cli/helpers.ts +50 -0
- package/src/inputFiles.ts +278 -0
- package/src/lintAssemblyInput.ts +89 -0
- package/src/lintAssemblyInstructions.ts +72 -0
- package/src/robots.ts +317 -0
- package/src/tus.ts +91 -5
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
import parse from 'json-to-ast';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { entries } from "./object.js";
|
|
4
|
+
import { addUseReference, botNeedsInput, doesStepRobotSupportUse, getFirstStepNameThatDoesNotNeedInput, getIndentation, hasRobot, parseSafeTemplate, } from "./stepParsing.js";
|
|
5
|
+
import { robotsMeta } from "./types/robots/_index.js";
|
|
6
|
+
import { stackVersions } from "./types/stackVersions.js";
|
|
7
|
+
import { assemblyInstructionsSchema } from "./types/template.js";
|
|
8
|
+
import { zodParseWithContext } from "./zodParseWithContext.js";
|
|
9
|
+
// Maximum number of steps allowed in a Smart CDN Assembly
|
|
10
|
+
// We set this ~unreasonably high for now as it could already avoid misuse/abuse
|
|
11
|
+
// until we have settled on a discussion about limits:
|
|
12
|
+
// See: https://github.com/transloadit/content/pull/4176
|
|
13
|
+
const MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY = 20;
|
|
14
|
+
const getStepLocation = (steps, stepName) => ({
|
|
15
|
+
row: steps.__line?.[stepName] ?? 0,
|
|
16
|
+
column: steps.__column?.[stepName] ?? 0,
|
|
17
|
+
});
|
|
18
|
+
const fixWrongStackVersionSchema = z.object({
|
|
19
|
+
stepName: z.string(),
|
|
20
|
+
paramName: z.string(),
|
|
21
|
+
recommendedVersion: z.string(),
|
|
22
|
+
});
|
|
23
|
+
const fixMissingUseSchema = z.object({
|
|
24
|
+
stepName: z.string(),
|
|
25
|
+
});
|
|
26
|
+
const fixDuplicateKeyInStepSchema = z.object({
|
|
27
|
+
stepName: z.string(),
|
|
28
|
+
duplicateKeys: z.array(z.string()),
|
|
29
|
+
});
|
|
30
|
+
const fixSmartCdnInputFieldSchema = z.object({
|
|
31
|
+
stepName: z.string(),
|
|
32
|
+
});
|
|
33
|
+
const fixMissingInputSchema = z.object({});
|
|
34
|
+
const fixMissingStepsSchema = z.object({});
|
|
35
|
+
const fixInvalidStepsTypeSchema = z.object({});
|
|
36
|
+
const fixEmptyStepsSchema = z.object({});
|
|
37
|
+
const fixMissingOriginalStorageSchema = z.object({});
|
|
38
|
+
class ParseError extends SyntaxError {
|
|
39
|
+
line;
|
|
40
|
+
column;
|
|
41
|
+
rawMessage;
|
|
42
|
+
source;
|
|
43
|
+
constructor(message, line, column, rawMessage, source) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.line = line;
|
|
46
|
+
this.column = column;
|
|
47
|
+
this.rawMessage = rawMessage;
|
|
48
|
+
this.source = source;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isObject(obj) {
|
|
52
|
+
return typeof obj === 'object' && !Array.isArray(obj) && obj !== null;
|
|
53
|
+
}
|
|
54
|
+
function has(object, key) {
|
|
55
|
+
return Object.hasOwn(object, key);
|
|
56
|
+
}
|
|
57
|
+
function isParseError(e) {
|
|
58
|
+
return (e instanceof Error &&
|
|
59
|
+
isObject(e) &&
|
|
60
|
+
'line' in e &&
|
|
61
|
+
typeof e.line === 'number' &&
|
|
62
|
+
'column' in e &&
|
|
63
|
+
typeof e.column === 'number' &&
|
|
64
|
+
'rawMessage' in e &&
|
|
65
|
+
typeof e.rawMessage === 'string');
|
|
66
|
+
}
|
|
67
|
+
// getASTValue traverses through the provided AST and will return
|
|
68
|
+
// the JavaScript value described by it.
|
|
69
|
+
// Objects and arrays will also have the __line and __column
|
|
70
|
+
// properties containing line and column number for their
|
|
71
|
+
// child elements.
|
|
72
|
+
// See https://github.com/vtrushin/json-to-ast#node-types
|
|
73
|
+
function getASTValue(ast) {
|
|
74
|
+
switch (ast.type) {
|
|
75
|
+
case 'Literal':
|
|
76
|
+
return ast.value;
|
|
77
|
+
case 'Array': {
|
|
78
|
+
const value = [];
|
|
79
|
+
const lines = [];
|
|
80
|
+
const columns = [];
|
|
81
|
+
for (const property of ast.children) {
|
|
82
|
+
if (property.loc) {
|
|
83
|
+
// json-to-ast starts the line and column numbers at 1 but the
|
|
84
|
+
// ace editor expects them to start at 0. To make up for that
|
|
85
|
+
// difference we subtract 1.
|
|
86
|
+
value.push(getASTValue(property));
|
|
87
|
+
lines.push(property.loc.start.line - 1);
|
|
88
|
+
columns.push(property.loc.start.column - 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
Object.defineProperty(value, '__line', { value: lines });
|
|
92
|
+
Object.defineProperty(value, '__column', { value: columns });
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
case 'Object': {
|
|
96
|
+
const value = {};
|
|
97
|
+
const lines = {};
|
|
98
|
+
const columns = {};
|
|
99
|
+
for (const property of ast.children) {
|
|
100
|
+
if (property.key && property.value && property.value.loc) {
|
|
101
|
+
// json-to-ast starts the line and column numbers at 1 but the
|
|
102
|
+
// ace editor expects them to start at 0. To make up for that
|
|
103
|
+
// difference we subtract 1.
|
|
104
|
+
value[property.key.value] = getASTValue(property.value);
|
|
105
|
+
lines[property.key.value] = property.value.loc.start.line - 1;
|
|
106
|
+
columns[property.key.value] = property.value.loc.start.column - 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
Object.defineProperty(value, '__line', { value: lines });
|
|
110
|
+
Object.defineProperty(value, '__column', { value: columns });
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
// Should not happen for valid ValueNode types
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// getRobotsUsingTool returns an array of the robots names
|
|
119
|
+
// which have a specific tool. This can be used to
|
|
120
|
+
// get all robots supporting the ffmpeg_stack setting, for example.
|
|
121
|
+
function getRobotsUsingTool(tool) {
|
|
122
|
+
return Object.entries(robotsMeta)
|
|
123
|
+
.filter(([, meta]) => !tool || meta.uses_tools?.includes(tool))
|
|
124
|
+
.map(([varName]) => {
|
|
125
|
+
// turn: audioArtworkMeta -> /audio/artwork
|
|
126
|
+
// turn: s3StoreMeta -> /s3/store
|
|
127
|
+
const rName = `/${varName
|
|
128
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1/$2')
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/\/meta$/, '')}`;
|
|
131
|
+
return rName;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const STORE_ROBOT_NAME = /^\/[a-z0-9]+\/store$/i;
|
|
135
|
+
function isStoreRobot(name) {
|
|
136
|
+
return STORE_ROBOT_NAME.test(name);
|
|
137
|
+
}
|
|
138
|
+
const IMPORT_ROBOT_NAME = /^\/[a-z0-9]+\/import$/i;
|
|
139
|
+
function isImportRobot(name) {
|
|
140
|
+
return IMPORT_ROBOT_NAME.test(name);
|
|
141
|
+
}
|
|
142
|
+
const FFMPEG_ROBOT_NAMES = getRobotsUsingTool('ffmpeg');
|
|
143
|
+
function isFfmpegRobot(name) {
|
|
144
|
+
return FFMPEG_ROBOT_NAMES.some((x) => x === name);
|
|
145
|
+
}
|
|
146
|
+
const IMAGICK_ROBOT_NAMES = getRobotsUsingTool('imagemagick');
|
|
147
|
+
function isImagickRobot(name) {
|
|
148
|
+
return IMAGICK_ROBOT_NAMES.some((x) => x === name);
|
|
149
|
+
}
|
|
150
|
+
const ALL_ROBOT_NAMES = getRobotsUsingTool();
|
|
151
|
+
function isRobot(name) {
|
|
152
|
+
return ALL_ROBOT_NAMES.includes(name);
|
|
153
|
+
}
|
|
154
|
+
function isHttpImportRobot(name) {
|
|
155
|
+
return name === '/http/import';
|
|
156
|
+
}
|
|
157
|
+
// lintStackParameter validates whether a given step has a proper
|
|
158
|
+
// ffmpeg_stack or imagemagick_stack paramater with an existing version.
|
|
159
|
+
// Which parameter is expected is controlled by the stackName
|
|
160
|
+
// argument which should either by 'ffmpeg' or 'imagemagick'.
|
|
161
|
+
// If a linting issue is found, the corresponding message is added
|
|
162
|
+
// to the result array.
|
|
163
|
+
function lintStackParameter(step, stepName, steps, stackName, result) {
|
|
164
|
+
const paramName = `${stackName}_stack`;
|
|
165
|
+
// Stack parameters are optional; when omitted, Transloadit defaults apply.
|
|
166
|
+
if (has(step, paramName)) {
|
|
167
|
+
const stackVersionValue = step[paramName];
|
|
168
|
+
if (typeof stackVersionValue === 'string') {
|
|
169
|
+
if (!stackVersions[stackName].test.test(stackVersionValue)) {
|
|
170
|
+
result.push({
|
|
171
|
+
code: `wrong-${stackName}-version`,
|
|
172
|
+
stepName,
|
|
173
|
+
robot: step.robot,
|
|
174
|
+
isAudioRobot: step.robot?.indexOf('/audio/') === 0,
|
|
175
|
+
stackVersion: stackVersionValue,
|
|
176
|
+
type: 'error',
|
|
177
|
+
row: steps.__line[stepName],
|
|
178
|
+
column: steps.__column[stepName],
|
|
179
|
+
fixId: 'fix-wrong-stack-version',
|
|
180
|
+
fixData: {
|
|
181
|
+
stepName,
|
|
182
|
+
paramName,
|
|
183
|
+
recommendedVersion: stackVersions[stackName].recommendedVersion,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// Handle cases where the stack parameter is present but not a string (though schema should catch this)
|
|
190
|
+
// Or, decide if this case is impossible due to schema validation and remove this else.
|
|
191
|
+
// For now, let's assume schema validation makes this path unlikely for a 'wrong-version' error,
|
|
192
|
+
// but a general 'schema-violation' might be more appropriate if this state is reached.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function lintUseArray(use, stepName, stepNames, result, row, column) {
|
|
197
|
+
if (!Array.isArray(use))
|
|
198
|
+
return;
|
|
199
|
+
if (use.length === 0) {
|
|
200
|
+
result.push({
|
|
201
|
+
code: 'empty-use-array',
|
|
202
|
+
stepName,
|
|
203
|
+
type: 'warning',
|
|
204
|
+
row: row ?? 0,
|
|
205
|
+
column: column ?? 0,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
use.forEach((obj, index) => {
|
|
210
|
+
let name;
|
|
211
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
212
|
+
name = obj.name;
|
|
213
|
+
}
|
|
214
|
+
else if (typeof obj === 'string') {
|
|
215
|
+
name = obj;
|
|
216
|
+
}
|
|
217
|
+
if (name && stepNames.indexOf(name) === -1) {
|
|
218
|
+
result.push({
|
|
219
|
+
code: 'undefined-step',
|
|
220
|
+
stepName,
|
|
221
|
+
wrongStepName: name,
|
|
222
|
+
type: 'error',
|
|
223
|
+
row: typeof obj === 'object' && obj !== null && obj.__line?.[index]
|
|
224
|
+
? obj.__line[index]
|
|
225
|
+
: (row ?? 0),
|
|
226
|
+
column: typeof obj === 'object' && obj !== null && obj.__column?.[index]
|
|
227
|
+
? obj.__column[index]
|
|
228
|
+
: (column ?? 0),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function lintHttpImportUrl(step, stepName, result) {
|
|
234
|
+
if (!has(step, 'url')) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const { url } = step;
|
|
238
|
+
if (typeof url !== 'string') {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Check if the URL contains a field variable without a protocol or domain
|
|
242
|
+
// Only warn when the URL is exactly an interpolation to avoid false positives.
|
|
243
|
+
const fieldVariableRegex = /^\$\{fields\.[^}]+\}$/;
|
|
244
|
+
const protocolDomainRegex = /^(https?:\/\/|\/\/)[^/]+/i;
|
|
245
|
+
if (fieldVariableRegex.test(url) && !protocolDomainRegex.test(url)) {
|
|
246
|
+
result.push({
|
|
247
|
+
code: 'unqualified-http-import-url',
|
|
248
|
+
stepName,
|
|
249
|
+
type: 'warning',
|
|
250
|
+
row: step.__line.url,
|
|
251
|
+
column: step.__column.url,
|
|
252
|
+
message: 'The /http/import url should include a protocol and domain name for security reasons.',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function lint(assembly) {
|
|
257
|
+
const result = [];
|
|
258
|
+
if (!isObject(assembly) || !('steps' in assembly)) {
|
|
259
|
+
result.push({
|
|
260
|
+
code: 'missing-steps',
|
|
261
|
+
type: 'error',
|
|
262
|
+
row: 0,
|
|
263
|
+
column: 0,
|
|
264
|
+
message: "The 'steps' property is missing",
|
|
265
|
+
fixId: 'fix-missing-steps',
|
|
266
|
+
fixData: {},
|
|
267
|
+
});
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
if (!isObject(assembly.steps)) {
|
|
271
|
+
result.push({
|
|
272
|
+
code: 'invalid-steps-type',
|
|
273
|
+
type: 'error',
|
|
274
|
+
row: assembly.__line?.steps ?? 0,
|
|
275
|
+
column: assembly.__column?.steps ?? 0,
|
|
276
|
+
message: "The 'steps' property must be an object",
|
|
277
|
+
fixId: 'fix-invalid-steps-type',
|
|
278
|
+
fixData: {},
|
|
279
|
+
});
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
if (Object.keys(assembly.steps).length === 0) {
|
|
283
|
+
result.push({
|
|
284
|
+
code: 'empty-steps',
|
|
285
|
+
type: 'warning',
|
|
286
|
+
row: assembly.__line?.steps ?? 0,
|
|
287
|
+
column: assembly.__column?.steps ?? 0,
|
|
288
|
+
message: "The 'steps' object is empty",
|
|
289
|
+
fixId: 'fix-empty-steps',
|
|
290
|
+
fixData: {},
|
|
291
|
+
});
|
|
292
|
+
return result; // Return here to avoid additional checks for empty steps
|
|
293
|
+
}
|
|
294
|
+
if (!isObject(assembly.steps)) {
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
const steps = assembly.steps;
|
|
298
|
+
const stepNames = Object.keys(steps).filter((key) => key !== '__line' && key !== '__column');
|
|
299
|
+
if (!stepNames.includes(':original')) {
|
|
300
|
+
// The step :original always exists automatically
|
|
301
|
+
stepNames.push(':original');
|
|
302
|
+
}
|
|
303
|
+
let hasFileServe = false;
|
|
304
|
+
let hasFieldsInput = false;
|
|
305
|
+
let importStepName = '';
|
|
306
|
+
// First pass - check for /file/serve and ${fields.input}
|
|
307
|
+
for (const [stepName, step] of Object.entries(steps)) {
|
|
308
|
+
if (stepName === '__line' || stepName === '__column')
|
|
309
|
+
continue;
|
|
310
|
+
if (!isObject(step))
|
|
311
|
+
continue;
|
|
312
|
+
// Ensure 'step' is actually a StepWithMetadata-like object, not just Record<string, number>
|
|
313
|
+
// A simple check for 'robot' or other StepInput fields can make the cast safer.
|
|
314
|
+
// StepInput is { robot?: string; use?: unknown; ... } & Record<string, unknown>
|
|
315
|
+
// StepWithMetadata adds __line and __column to StepInput.
|
|
316
|
+
// A Record<string, number> would not typically have these specific fields like 'robot'.
|
|
317
|
+
if (!('robot' in step || 'use' in step)) {
|
|
318
|
+
// This object doesn't look like a step, skip or handle as an error.
|
|
319
|
+
// For now, let's assume it might be an invalid structure caught by schema validation later
|
|
320
|
+
// or it's a case not expected here if it passed earlier checks.
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const typedStep = step;
|
|
324
|
+
if (!typedStep.robot)
|
|
325
|
+
continue;
|
|
326
|
+
// Check if we have a /file/serve robot anywhere
|
|
327
|
+
if (typedStep.robot === '/file/serve') {
|
|
328
|
+
hasFileServe = true;
|
|
329
|
+
}
|
|
330
|
+
// Check if we use ${fields.input} in any import step
|
|
331
|
+
if (isImportRobot(typedStep.robot)) {
|
|
332
|
+
importStepName = stepName;
|
|
333
|
+
const stepStr = JSON.stringify(step);
|
|
334
|
+
if (stepStr.includes('${fields.input}')) {
|
|
335
|
+
hasFieldsInput = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// If we have /file/serve but don't use ${fields.input} in the import step, add warning
|
|
340
|
+
if (hasFileServe && !hasFieldsInput && importStepName) {
|
|
341
|
+
const { row, column } = getStepLocation(steps, importStepName);
|
|
342
|
+
result.push({
|
|
343
|
+
code: 'smart-cdn-input-field-missing',
|
|
344
|
+
type: 'warning',
|
|
345
|
+
row,
|
|
346
|
+
column,
|
|
347
|
+
message: 'Smart CDN path component available as `${fields.input}`',
|
|
348
|
+
stepName: importStepName,
|
|
349
|
+
fixId: 'fix-smart-cdn-input-field',
|
|
350
|
+
fixData: { stepName: importStepName },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
let usesOriginalFiles = false;
|
|
354
|
+
let storesOriginalFiles = false;
|
|
355
|
+
let hasInputStep = false;
|
|
356
|
+
for (const [stepName, step] of Object.entries(steps)) {
|
|
357
|
+
if (stepName === '__line' || stepName === '__column')
|
|
358
|
+
continue;
|
|
359
|
+
const { row, column } = getStepLocation(steps, stepName);
|
|
360
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
361
|
+
result.push({
|
|
362
|
+
code: 'step-is-not-an-object',
|
|
363
|
+
stepName,
|
|
364
|
+
type: 'error',
|
|
365
|
+
row,
|
|
366
|
+
column,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const stepKeys = Object.keys(step).filter((key) => key !== '__line' && key !== '__column');
|
|
371
|
+
if (!('robot' in step || 'use' in step)) {
|
|
372
|
+
if (stepKeys.length > 0) {
|
|
373
|
+
result.push({
|
|
374
|
+
code: 'missing-robot',
|
|
375
|
+
stepName,
|
|
376
|
+
type: 'error',
|
|
377
|
+
row,
|
|
378
|
+
column,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const typedStep = step;
|
|
384
|
+
if (!typedStep.robot) {
|
|
385
|
+
result.push({
|
|
386
|
+
code: 'missing-robot',
|
|
387
|
+
stepName,
|
|
388
|
+
type: 'error',
|
|
389
|
+
row,
|
|
390
|
+
column,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
else if (!isRobot(typedStep.robot)) {
|
|
395
|
+
result.push({
|
|
396
|
+
code: 'undefined-robot',
|
|
397
|
+
stepName,
|
|
398
|
+
robot: typedStep.robot,
|
|
399
|
+
type: 'error',
|
|
400
|
+
row: typedStep.__line.robot,
|
|
401
|
+
column: typedStep.__column.robot,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
else if (typedStep.robot === '/file/serve') {
|
|
405
|
+
hasFileServe = true;
|
|
406
|
+
if ('url' in typedStep && !('use' in typedStep)) {
|
|
407
|
+
const stepStr = JSON.stringify(step);
|
|
408
|
+
if (!stepStr.includes('${fields.input}')) {
|
|
409
|
+
result.push({
|
|
410
|
+
code: 'smart-cdn-input-field-missing',
|
|
411
|
+
type: 'warning',
|
|
412
|
+
row,
|
|
413
|
+
column,
|
|
414
|
+
message: 'Smart CDN path component available as `${fields.input}`',
|
|
415
|
+
stepName,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (isFfmpegRobot(typedStep.robot)) {
|
|
421
|
+
lintStackParameter(typedStep, stepName, steps, 'ffmpeg', result);
|
|
422
|
+
}
|
|
423
|
+
else if (isImagickRobot(typedStep.robot)) {
|
|
424
|
+
lintStackParameter(typedStep, stepName, steps, 'imagemagick', result);
|
|
425
|
+
}
|
|
426
|
+
else if (typedStep.robot === '/upload/handle') {
|
|
427
|
+
if (stepName !== ':original') {
|
|
428
|
+
result.push({
|
|
429
|
+
code: 'wrong-step-name',
|
|
430
|
+
type: 'error',
|
|
431
|
+
row,
|
|
432
|
+
column,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (isHttpImportRobot(typedStep.robot)) {
|
|
437
|
+
lintHttpImportUrl(typedStep, stepName, result);
|
|
438
|
+
}
|
|
439
|
+
if (!has(typedStep, 'use')) {
|
|
440
|
+
if (typedStep.robot === '/html/convert') {
|
|
441
|
+
// The /html/convert robot can either act as a import robot when
|
|
442
|
+
// the `url` parameter is defined. Or it can be a conversion robot
|
|
443
|
+
// if `use` is available. If neither of those parameters is given,
|
|
444
|
+
// we emit a warning.
|
|
445
|
+
if (has(typedStep, 'url')) {
|
|
446
|
+
hasInputStep = true;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
result.push({
|
|
450
|
+
code: 'missing-url',
|
|
451
|
+
stepName,
|
|
452
|
+
type: 'warning',
|
|
453
|
+
row,
|
|
454
|
+
column,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else if (
|
|
459
|
+
// Check if this robot doesn't need input (like import robots, /upload/handle,
|
|
460
|
+
// file-generating robots like /image/generate, /text/speak with prompt, etc.)
|
|
461
|
+
!botNeedsInput(typedStep.robot, stepName, typedStep)) {
|
|
462
|
+
hasInputStep = true;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
// Import robots and /upload/handle do not need a use parameter. For
|
|
466
|
+
// all others we emit a warning.
|
|
467
|
+
result.push({
|
|
468
|
+
code: 'missing-use',
|
|
469
|
+
stepName,
|
|
470
|
+
type: 'warning',
|
|
471
|
+
row,
|
|
472
|
+
column,
|
|
473
|
+
fixId: 'fix-missing-use',
|
|
474
|
+
fixData: { stepName },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
if (Array.isArray(typedStep.use)) {
|
|
480
|
+
const referencesOriginal = typedStep.use.some((item) => {
|
|
481
|
+
if (typeof item === 'string') {
|
|
482
|
+
return item === ':original';
|
|
483
|
+
}
|
|
484
|
+
return (typeof item === 'object' && item !== null && 'name' in item && item.name === ':original');
|
|
485
|
+
});
|
|
486
|
+
if (referencesOriginal) {
|
|
487
|
+
hasInputStep = true;
|
|
488
|
+
}
|
|
489
|
+
// Situation 1: use parameter is an array, for example
|
|
490
|
+
// "use": [ "hello", { name: ":original" } ]
|
|
491
|
+
lintUseArray(typedStep.use, stepName, stepNames, result, typedStep.__line.use, typedStep.__column.use);
|
|
492
|
+
}
|
|
493
|
+
else if (typeof typedStep.use === 'object' && typedStep.use !== null) {
|
|
494
|
+
// Situation 2: use parameter is an object, for example
|
|
495
|
+
// "use": { steps: [ "hello", "hi" ], "bundle_steps": true }
|
|
496
|
+
// typedStep.use here is inferred as StepUse, which can be StepUseObject
|
|
497
|
+
// StepUseObject has a MANDATORY 'steps' property of type StepUseArrayItemSchema[]
|
|
498
|
+
const useObject = typedStep.use; // No immediate cast
|
|
499
|
+
if ('steps' in useObject) {
|
|
500
|
+
// Access metadata for the 'steps' key within the useObject, if available.
|
|
501
|
+
// The useObject itself, being a product of getASTValue for an object, should have __line/__column.
|
|
502
|
+
const useStepsLine = useObject?.__line?.steps;
|
|
503
|
+
const useStepsColumn = useObject?.__column?.steps;
|
|
504
|
+
if (Array.isArray(useObject.steps)) {
|
|
505
|
+
// Now useObject.steps is known to be an array.
|
|
506
|
+
// We still need to ensure elements match StepUseArrayItemSchema if processing them.
|
|
507
|
+
// The existing lintUseArray function takes 'unknown[]' for its first arg's 'steps' property if it's an object, so this is compatible.
|
|
508
|
+
if (useObject.steps.some((step) => {
|
|
509
|
+
if (typeof step === 'string') {
|
|
510
|
+
return step === ':original';
|
|
511
|
+
}
|
|
512
|
+
return (typeof step === 'object' &&
|
|
513
|
+
step !== null &&
|
|
514
|
+
'name' in step &&
|
|
515
|
+
step.name === ':original');
|
|
516
|
+
})) {
|
|
517
|
+
hasInputStep = true;
|
|
518
|
+
}
|
|
519
|
+
lintUseArray(useObject.steps, stepName, stepNames, result, useStepsLine ?? typedStep.__line.use, // Fallback to the line of the 'use' key itself
|
|
520
|
+
useStepsColumn ?? typedStep.__column.use);
|
|
521
|
+
}
|
|
522
|
+
else if (typeof useObject.steps === 'string') {
|
|
523
|
+
if (useObject.steps === ':original') {
|
|
524
|
+
hasInputStep = true;
|
|
525
|
+
}
|
|
526
|
+
lintUseArray([useObject.steps], stepName, stepNames, result, useStepsLine ?? typedStep.__line.use, useStepsColumn ?? typedStep.__column.use);
|
|
527
|
+
}
|
|
528
|
+
else if (typeof useObject.steps !== 'string') {
|
|
529
|
+
// If 'steps' is not an array or not present, it's an invalid use object structure.
|
|
530
|
+
result.push({
|
|
531
|
+
code: 'missing-use-steps', // Or a more specific error like 'invalid-use-object-structure'
|
|
532
|
+
stepName,
|
|
533
|
+
type: 'error',
|
|
534
|
+
row: typedStep.__line.use, // Point to the start of the use object
|
|
535
|
+
column: typedStep.__column.use,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
else if (typeof typedStep.use === 'string') {
|
|
541
|
+
if (typedStep.use === ':original') {
|
|
542
|
+
hasInputStep = true;
|
|
543
|
+
}
|
|
544
|
+
// Situation 3: use parameter is a string, for example
|
|
545
|
+
// "use": "import"
|
|
546
|
+
if (stepNames.indexOf(typedStep.use) === -1) {
|
|
547
|
+
result.push({
|
|
548
|
+
code: 'undefined-step',
|
|
549
|
+
stepName,
|
|
550
|
+
wrongStepName: typedStep.use,
|
|
551
|
+
type: 'error',
|
|
552
|
+
row: typedStep.__line.use,
|
|
553
|
+
column: typedStep.__column.use,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
// Situation 4: use parameter has some other invalid type
|
|
559
|
+
result.push({
|
|
560
|
+
code: 'wrong-use-type',
|
|
561
|
+
stepName,
|
|
562
|
+
type: 'error',
|
|
563
|
+
row: typedStep.__line.use,
|
|
564
|
+
column: typedStep.__column.use,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const referencesOriginalFiles = JSON.stringify(typedStep.use).includes(':original');
|
|
568
|
+
if (referencesOriginalFiles) {
|
|
569
|
+
if (typedStep.robot && isStoreRobot(typedStep.robot)) {
|
|
570
|
+
storesOriginalFiles = true;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
usesOriginalFiles = true;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// When the /file/serve robot is used for the UrlProxy, customers should not use a
|
|
579
|
+
// storage robot, so we should not warn them about it.
|
|
580
|
+
if (!hasFileServe) {
|
|
581
|
+
const hasStorageRobot = hasRobot(JSON.stringify(assembly), /\/store$/, true);
|
|
582
|
+
if (!hasStorageRobot) {
|
|
583
|
+
result.push({
|
|
584
|
+
code: 'no-storage',
|
|
585
|
+
type: 'warning',
|
|
586
|
+
row: assembly.__line?.steps ?? 0,
|
|
587
|
+
column: assembly.__column?.steps ?? 0,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (usesOriginalFiles && !storesOriginalFiles && hasStorageRobot) {
|
|
591
|
+
// Keep only the missing-original-storage warning
|
|
592
|
+
result.push({
|
|
593
|
+
code: 'missing-original-storage',
|
|
594
|
+
type: 'warning',
|
|
595
|
+
row: assembly.__line?.steps ?? 0,
|
|
596
|
+
column: assembly.__column?.steps ?? 0,
|
|
597
|
+
fixId: 'fix-missing-original-storage',
|
|
598
|
+
fixData: {},
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!hasInputStep) {
|
|
603
|
+
result.push({
|
|
604
|
+
code: 'missing-input',
|
|
605
|
+
type: 'error',
|
|
606
|
+
row: assembly.__line?.steps ?? 0,
|
|
607
|
+
column: assembly.__column?.steps ?? 0,
|
|
608
|
+
fixId: 'fix-missing-input',
|
|
609
|
+
fixData: {}, // Add an empty object as fixData
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// Add schema violations as linting issues, only if we don't have any
|
|
613
|
+
// serious linting issues yet. Otherwise we risk having duplicate
|
|
614
|
+
// issues, for example, for ffmpeg_stack. Both the linter and the schema cover it.
|
|
615
|
+
// @TODO: In the future we should delete Linter issues that are covered by the Schema.
|
|
616
|
+
// It could result in just having only a few Linter issues left.
|
|
617
|
+
const cntErrors = result.filter((r) => r.type === 'error').length;
|
|
618
|
+
// const cntWarnings = result.filter((r) => r.type === 'warning').length
|
|
619
|
+
if (!cntErrors) {
|
|
620
|
+
const parsed = zodParseWithContext(assemblyInstructionsSchema, assembly);
|
|
621
|
+
if (!parsed.success) {
|
|
622
|
+
for (const zodIssue of parsed.errors) {
|
|
623
|
+
// Start with default values at the steps object level
|
|
624
|
+
let row = assembly.__line?.steps ?? 1;
|
|
625
|
+
let column = assembly.__column?.steps ?? 1;
|
|
626
|
+
const { path } = zodIssue;
|
|
627
|
+
// Find the row and column of this path in the JSON string:
|
|
628
|
+
if (path.length > 0) {
|
|
629
|
+
let current = assembly;
|
|
630
|
+
let metadata = assembly;
|
|
631
|
+
// Walk the path to find the deepest available line/column info
|
|
632
|
+
for (const segment of path) {
|
|
633
|
+
if (typeof segment === 'string' && current && typeof current === 'object') {
|
|
634
|
+
// Keep track of both the actual value and its metadata
|
|
635
|
+
current = current[segment];
|
|
636
|
+
// The metadata contains __line and __column info
|
|
637
|
+
if (metadata && '__line' in metadata && '__column' in metadata) {
|
|
638
|
+
const lines = metadata.__line;
|
|
639
|
+
const columns = metadata.__column;
|
|
640
|
+
if (segment in lines && segment in columns) {
|
|
641
|
+
row = lines[segment];
|
|
642
|
+
column = columns[segment];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Update metadata pointer for next iteration
|
|
646
|
+
metadata = current;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
result.push({
|
|
651
|
+
code: 'schema-violation',
|
|
652
|
+
type: 'error',
|
|
653
|
+
row,
|
|
654
|
+
column,
|
|
655
|
+
message: zodIssue.humanReadable,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
function isInfiniteAssembly(template) {
|
|
663
|
+
if (!template.steps)
|
|
664
|
+
return [false];
|
|
665
|
+
const graph = new Map();
|
|
666
|
+
for (const [stepName, stepValue] of Object.entries(template.steps)) {
|
|
667
|
+
if (stepName === '__line' || stepName === '__column')
|
|
668
|
+
continue;
|
|
669
|
+
if (typeof stepValue !== 'object' ||
|
|
670
|
+
stepValue === null ||
|
|
671
|
+
!('use' in stepValue) ||
|
|
672
|
+
!stepValue.use) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const stepUseValue = stepValue.use;
|
|
676
|
+
if (typeof stepUseValue === 'string') {
|
|
677
|
+
graph.set(stepName, [stepUseValue]);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (Array.isArray(stepUseValue)) {
|
|
681
|
+
// Filter out non-string/non-object-with-name items to satisfy .every checks
|
|
682
|
+
const filteredUseArray = stepUseValue.filter((u) => typeof u === 'string' ||
|
|
683
|
+
(typeof u === 'object' && u !== null && 'name' in u && typeof u.name === 'string'));
|
|
684
|
+
if (filteredUseArray.every((u) => typeof u === 'string')) {
|
|
685
|
+
graph.set(stepName, filteredUseArray);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (filteredUseArray.every((u) => typeof u === 'object' && u !== null && 'name' in u)) {
|
|
689
|
+
graph.set(stepName, filteredUseArray.map((u) => u.name));
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (typeof stepUseValue === 'object' &&
|
|
694
|
+
stepUseValue !== null &&
|
|
695
|
+
'steps' in stepUseValue &&
|
|
696
|
+
Array.isArray(stepUseValue.steps)) {
|
|
697
|
+
const useSteps = stepUseValue.steps;
|
|
698
|
+
// Similar filtering as above for useSteps elements
|
|
699
|
+
const filteredUseSteps = useSteps.filter((s) => typeof s === 'string' ||
|
|
700
|
+
(typeof s === 'object' && s !== null && 'name' in s && typeof s.name === 'string'));
|
|
701
|
+
if (filteredUseSteps.every((s) => typeof s === 'string')) {
|
|
702
|
+
graph.set(stepName, filteredUseSteps);
|
|
703
|
+
}
|
|
704
|
+
else if (filteredUseSteps.every((s) => typeof s === 'object' && s !== null && 'name' in s)) {
|
|
705
|
+
graph.set(stepName, filteredUseSteps.map((s) => s.name));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const visited = new Set();
|
|
710
|
+
const recursionStack = new Set();
|
|
711
|
+
function dfs(node) {
|
|
712
|
+
if (recursionStack.has(node))
|
|
713
|
+
return true; // Cycle detected
|
|
714
|
+
if (visited.has(node))
|
|
715
|
+
return false; // Already visited and no cycle from this node
|
|
716
|
+
visited.add(node);
|
|
717
|
+
recursionStack.add(node);
|
|
718
|
+
const neighbors = graph.get(node) || [];
|
|
719
|
+
for (const neighbor of neighbors) {
|
|
720
|
+
// One of the pitfalls of our normalization is that an :original step
|
|
721
|
+
// references itself in its own use property after normalization.
|
|
722
|
+
// This is an "accepted" circular dependency.
|
|
723
|
+
if (node === ':original' && neighbor !== ':original') {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (dfs(neighbor)) {
|
|
727
|
+
return true; // Cycle detected in recursion
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
recursionStack.delete(node);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
for (const [stepName, stepValue] of Object.entries(template.steps)) {
|
|
734
|
+
if (stepName === '__line' || stepName === '__column')
|
|
735
|
+
continue;
|
|
736
|
+
if (!graph.has(stepName))
|
|
737
|
+
continue;
|
|
738
|
+
if (!visited.has(stepName) && dfs(stepName)) {
|
|
739
|
+
const offendingStep = stepValue; // Cast for __line/__column access
|
|
740
|
+
return [
|
|
741
|
+
true,
|
|
742
|
+
{
|
|
743
|
+
stepName,
|
|
744
|
+
line: offendingStep.__line?.use ?? 0, // Assumes 'use' is a key in __line for the property itself
|
|
745
|
+
column: offendingStep.__column?.use ?? 0,
|
|
746
|
+
},
|
|
747
|
+
]; // Circular dependency found
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return [false]; // No circular dependencies detected
|
|
751
|
+
}
|
|
752
|
+
function findDuplicateKeysInAST(node, path = '', annotations = []) {
|
|
753
|
+
if (node.type === 'Object') {
|
|
754
|
+
const keysSeen = new Map();
|
|
755
|
+
for (const property of node.children) {
|
|
756
|
+
const key = property.key.value;
|
|
757
|
+
const keyLocation = property.key.loc;
|
|
758
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
759
|
+
if (keysSeen.has(key) && keyLocation) {
|
|
760
|
+
const stepName = path.includes('steps.') ? path.split('steps.')[1] : undefined;
|
|
761
|
+
// Duplicate key found
|
|
762
|
+
annotations.push({
|
|
763
|
+
code: 'duplicate-key-in-step',
|
|
764
|
+
type: 'warning',
|
|
765
|
+
row: keyLocation.start.line - 1,
|
|
766
|
+
column: keyLocation.start.column - 1,
|
|
767
|
+
message: `Duplicate key '${key}' found`,
|
|
768
|
+
stepName,
|
|
769
|
+
duplicateKeys: [key],
|
|
770
|
+
fixId: 'fix-duplicate-key-in-step',
|
|
771
|
+
fixData: {
|
|
772
|
+
stepName: stepName ?? '',
|
|
773
|
+
duplicateKeys: [key],
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
keysSeen.set(key, property.value);
|
|
779
|
+
}
|
|
780
|
+
// Recurse into the property value
|
|
781
|
+
findDuplicateKeysInAST(property.value, fullPath, annotations);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
else if (node.type === 'Array') {
|
|
785
|
+
for (const item of node.children) {
|
|
786
|
+
findDuplicateKeysInAST(item, path, annotations);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Checks if an assembly is a Smart CDN Assembly by looking for the `/file/serve` robot
|
|
792
|
+
*/
|
|
793
|
+
export function isSmartCdnAssembly(assembly) {
|
|
794
|
+
if (!isObject(assembly) || !isObject(assembly.steps)) {
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
for (const [stepName, step] of Object.entries(assembly.steps)) {
|
|
798
|
+
if (stepName === '__line' || stepName === '__column')
|
|
799
|
+
continue;
|
|
800
|
+
if (!isObject(step))
|
|
801
|
+
continue;
|
|
802
|
+
const typedStep = step;
|
|
803
|
+
if (typedStep.robot === '/file/serve') {
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
// This function counts the steps in an assembly
|
|
810
|
+
function countSteps(steps) {
|
|
811
|
+
// Filter out metadata properties
|
|
812
|
+
return Object.keys(steps).filter((key) => key !== '__line' && key !== '__column').length;
|
|
813
|
+
}
|
|
814
|
+
// Checks if a robot is allowed for Smart CDN
|
|
815
|
+
function isRobotAllowedForSmartCdn(robotName) {
|
|
816
|
+
if (!robotName || typeof robotName !== 'string') {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
// Convert robotName like /http/import to httpImportMeta
|
|
820
|
+
const parts = robotName.substring(1).split('/');
|
|
821
|
+
const keyBase = parts
|
|
822
|
+
.map((part, index) => {
|
|
823
|
+
if (index === 0)
|
|
824
|
+
return part;
|
|
825
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
826
|
+
})
|
|
827
|
+
.join('');
|
|
828
|
+
const robotMetaKey = `${keyBase}Meta`;
|
|
829
|
+
const meta = robotsMeta[robotMetaKey];
|
|
830
|
+
// Check if this robot exists and is allowed for Smart CDN
|
|
831
|
+
return meta?.allowed_for_url_transform === true;
|
|
832
|
+
}
|
|
833
|
+
// This function lints Smart CDN Assemblies
|
|
834
|
+
function lintSmartCdn(assembly) {
|
|
835
|
+
const results = [];
|
|
836
|
+
if (!assembly.steps || typeof assembly.steps !== 'object') {
|
|
837
|
+
return results;
|
|
838
|
+
}
|
|
839
|
+
const steps = assembly.steps;
|
|
840
|
+
// Check step count against limit
|
|
841
|
+
const stepCount = countSteps(steps);
|
|
842
|
+
if (stepCount > MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY) {
|
|
843
|
+
results.push({
|
|
844
|
+
code: 'smart-cdn-max-steps-exceeded',
|
|
845
|
+
type: 'error',
|
|
846
|
+
row: assembly.__line?.steps ?? 0,
|
|
847
|
+
column: assembly.__column?.steps ?? 0,
|
|
848
|
+
message: `Smart CDN Assemblies are limited to ${MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY} steps, but found ${stepCount} steps`,
|
|
849
|
+
maxStepCount: MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY,
|
|
850
|
+
stepCount,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
// Check for disallowed robots
|
|
854
|
+
for (const [stepName, step] of Object.entries(steps)) {
|
|
855
|
+
if (stepName === '__line' || stepName === '__column' || typeof step !== 'object' || !step) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const typedStep = step;
|
|
859
|
+
const robotNameValue = typedStep.robot;
|
|
860
|
+
if (robotNameValue && !isRobotAllowedForSmartCdn(robotNameValue)) {
|
|
861
|
+
const { row, column } = getStepLocation(steps, stepName);
|
|
862
|
+
results.push({
|
|
863
|
+
code: 'smart-cdn-robot-not-allowed',
|
|
864
|
+
type: 'error',
|
|
865
|
+
row,
|
|
866
|
+
column,
|
|
867
|
+
message: `Robot "${robotNameValue}" is not allowed in Smart CDN Assemblies`,
|
|
868
|
+
stepName,
|
|
869
|
+
robot: robotNameValue,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return results;
|
|
874
|
+
}
|
|
875
|
+
export async function parseAndLint(json) {
|
|
876
|
+
let ast;
|
|
877
|
+
try {
|
|
878
|
+
ast = parse(json, { loc: true });
|
|
879
|
+
}
|
|
880
|
+
catch (e) {
|
|
881
|
+
if (!(e instanceof Error)) {
|
|
882
|
+
throw e;
|
|
883
|
+
}
|
|
884
|
+
if (e.name !== 'SyntaxError') {
|
|
885
|
+
throw e;
|
|
886
|
+
}
|
|
887
|
+
if (!isParseError(e)) {
|
|
888
|
+
throw e;
|
|
889
|
+
}
|
|
890
|
+
return [
|
|
891
|
+
{
|
|
892
|
+
code: 'invalid-json',
|
|
893
|
+
type: 'error',
|
|
894
|
+
row: e.line - 1,
|
|
895
|
+
column: e.column - 1,
|
|
896
|
+
message: e.rawMessage,
|
|
897
|
+
},
|
|
898
|
+
];
|
|
899
|
+
}
|
|
900
|
+
const obj = getASTValue(ast);
|
|
901
|
+
const templateMeta = obj;
|
|
902
|
+
const annotations = lint(templateMeta);
|
|
903
|
+
// Additional checks for Smart CDN assemblies
|
|
904
|
+
if (isSmartCdnAssembly(templateMeta)) {
|
|
905
|
+
annotations.push(...lintSmartCdn(templateMeta));
|
|
906
|
+
}
|
|
907
|
+
findDuplicateKeysInAST(ast, undefined, annotations);
|
|
908
|
+
const [isInfinite, positionalInfo] = isInfiniteAssembly(templateMeta);
|
|
909
|
+
if (isInfinite && positionalInfo) {
|
|
910
|
+
annotations.push({
|
|
911
|
+
code: 'infinite-assembly',
|
|
912
|
+
type: 'error',
|
|
913
|
+
row: positionalInfo.line,
|
|
914
|
+
column: positionalInfo.column,
|
|
915
|
+
stepName: positionalInfo.stepName,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
// Sort the annotations by row numbers descending
|
|
919
|
+
annotations.sort((a, b) => a.row - b.row);
|
|
920
|
+
return annotations;
|
|
921
|
+
}
|
|
922
|
+
function fixWrongStackVersion(content, fixData) {
|
|
923
|
+
// A wrong stack version is a violation of our schema so we cannot use parseSafeTemplate
|
|
924
|
+
// here.
|
|
925
|
+
let parsed;
|
|
926
|
+
let indent = ' ';
|
|
927
|
+
try {
|
|
928
|
+
parsed = JSON.parse(content);
|
|
929
|
+
indent = getIndentation(content);
|
|
930
|
+
}
|
|
931
|
+
catch (_e) {
|
|
932
|
+
return content;
|
|
933
|
+
}
|
|
934
|
+
if (!isObject(parsed)) {
|
|
935
|
+
return content;
|
|
936
|
+
}
|
|
937
|
+
const parsedRecord = parsed;
|
|
938
|
+
const stepsValue = parsedRecord.steps;
|
|
939
|
+
if (!isObject(stepsValue)) {
|
|
940
|
+
return content;
|
|
941
|
+
}
|
|
942
|
+
const stepsRecord = stepsValue;
|
|
943
|
+
const newStepsEntries = [];
|
|
944
|
+
for (const [stepName2, step2] of Object.entries(stepsRecord)) {
|
|
945
|
+
if (typeof step2 !== 'object' || step2 === null) {
|
|
946
|
+
newStepsEntries.push([stepName2, step2]);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
let newStep = { ...step2 };
|
|
950
|
+
if (fixData.stepName === stepName2) {
|
|
951
|
+
newStep = { ...step2, [fixData.paramName]: fixData.recommendedVersion };
|
|
952
|
+
}
|
|
953
|
+
newStepsEntries.push([stepName2, newStep]);
|
|
954
|
+
}
|
|
955
|
+
return JSON.stringify({ ...parsedRecord, steps: Object.fromEntries(newStepsEntries) }, null, indent);
|
|
956
|
+
}
|
|
957
|
+
function fixMissingUse(content, fixData) {
|
|
958
|
+
// A missing use is a violation of our schema so we cannot use parseSafeTemplate
|
|
959
|
+
// here.
|
|
960
|
+
let parsed;
|
|
961
|
+
let indent = ' ';
|
|
962
|
+
try {
|
|
963
|
+
parsed = JSON.parse(content);
|
|
964
|
+
indent = getIndentation(content);
|
|
965
|
+
}
|
|
966
|
+
catch (_e) {
|
|
967
|
+
return content;
|
|
968
|
+
}
|
|
969
|
+
if (!isObject(parsed)) {
|
|
970
|
+
return content;
|
|
971
|
+
}
|
|
972
|
+
const parsedRecord = parsed;
|
|
973
|
+
const stepsValue = parsedRecord.steps;
|
|
974
|
+
if (!isObject(stepsValue)) {
|
|
975
|
+
return content;
|
|
976
|
+
}
|
|
977
|
+
const stepsRecord = stepsValue;
|
|
978
|
+
// Get the step that needs fixing
|
|
979
|
+
const stepValue = stepsRecord[fixData.stepName];
|
|
980
|
+
if (!isObject(stepValue) || !('robot' in stepValue)) {
|
|
981
|
+
return content;
|
|
982
|
+
}
|
|
983
|
+
const step = stepValue;
|
|
984
|
+
// Get the first upload or import step:
|
|
985
|
+
const firstInputStepName = getFirstStepNameThatDoesNotNeedInput(content);
|
|
986
|
+
if (!firstInputStepName) {
|
|
987
|
+
return content;
|
|
988
|
+
}
|
|
989
|
+
// Add the use parameter pointing to :original only if the robot supports it:
|
|
990
|
+
if (doesStepRobotSupportUse(step)) {
|
|
991
|
+
step.use = firstInputStepName;
|
|
992
|
+
}
|
|
993
|
+
parsedRecord.steps = stepsRecord;
|
|
994
|
+
return JSON.stringify(parsedRecord, null, indent);
|
|
995
|
+
}
|
|
996
|
+
function fixDuplicateKeyInStep(content, _fixData) {
|
|
997
|
+
const [templateError, template, indent] = parseSafeTemplate(content);
|
|
998
|
+
if (templateError) {
|
|
999
|
+
// If parsing fails, return the original content
|
|
1000
|
+
return content;
|
|
1001
|
+
}
|
|
1002
|
+
return JSON.stringify(template, null, indent);
|
|
1003
|
+
}
|
|
1004
|
+
function fixMissingSteps(content) {
|
|
1005
|
+
const [templateError, template, indent] = parseSafeTemplate(content);
|
|
1006
|
+
if (templateError) {
|
|
1007
|
+
return JSON.stringify({ steps: {} }, null, ' ');
|
|
1008
|
+
}
|
|
1009
|
+
return JSON.stringify({ ...template, steps: {} }, null, indent);
|
|
1010
|
+
}
|
|
1011
|
+
function fixMissingInput(content) {
|
|
1012
|
+
// A missing input is a violation of our schema so we cannot use parseSafeTemplate
|
|
1013
|
+
// here.
|
|
1014
|
+
let parsed;
|
|
1015
|
+
let indent = ' ';
|
|
1016
|
+
try {
|
|
1017
|
+
parsed = JSON.parse(content);
|
|
1018
|
+
indent = getIndentation(content);
|
|
1019
|
+
}
|
|
1020
|
+
catch (_e) {
|
|
1021
|
+
return content;
|
|
1022
|
+
}
|
|
1023
|
+
if (!isObject(parsed)) {
|
|
1024
|
+
return content;
|
|
1025
|
+
}
|
|
1026
|
+
const parsedRecord = parsed;
|
|
1027
|
+
const stepsValue = parsedRecord.steps;
|
|
1028
|
+
if (!isObject(stepsValue)) {
|
|
1029
|
+
return content;
|
|
1030
|
+
}
|
|
1031
|
+
const stepsRecord = stepsValue;
|
|
1032
|
+
// Add the :original step with /upload/handle robot
|
|
1033
|
+
stepsRecord[':original'] = {
|
|
1034
|
+
robot: '/upload/handle',
|
|
1035
|
+
};
|
|
1036
|
+
// Update other steps to use :original if they don't have a 'use' property
|
|
1037
|
+
for (const [stepName, step] of Object.entries(stepsRecord)) {
|
|
1038
|
+
if (stepName !== ':original' && isObject(step) && !('use' in step) && 'robot' in step) {
|
|
1039
|
+
// Use addUseReference instead of direct assignment
|
|
1040
|
+
// @ts-expect-error: robot should be good here
|
|
1041
|
+
const updatedStep = addUseReference(step, ':original');
|
|
1042
|
+
stepsRecord[stepName] = updatedStep;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
parsedRecord.steps = stepsRecord;
|
|
1046
|
+
return JSON.stringify(parsedRecord, null, indent);
|
|
1047
|
+
}
|
|
1048
|
+
function fixInvalidStepsType(content) {
|
|
1049
|
+
let parsed;
|
|
1050
|
+
let indent = ' ';
|
|
1051
|
+
try {
|
|
1052
|
+
parsed = JSON.parse(content);
|
|
1053
|
+
indent = getIndentation(content);
|
|
1054
|
+
}
|
|
1055
|
+
catch (_err) {
|
|
1056
|
+
return content;
|
|
1057
|
+
}
|
|
1058
|
+
if (!isObject(parsed)) {
|
|
1059
|
+
return content;
|
|
1060
|
+
}
|
|
1061
|
+
const parsedRecord = parsed;
|
|
1062
|
+
if (!isObject(parsedRecord.steps)) {
|
|
1063
|
+
parsedRecord.steps = {};
|
|
1064
|
+
}
|
|
1065
|
+
return JSON.stringify(parsedRecord, null, indent);
|
|
1066
|
+
}
|
|
1067
|
+
function fixEmptySteps(content) {
|
|
1068
|
+
const [templateError, template, indent] = parseSafeTemplate(content);
|
|
1069
|
+
if (templateError) {
|
|
1070
|
+
return content;
|
|
1071
|
+
}
|
|
1072
|
+
if (Object.keys(template.steps ?? {}).length === 0) {
|
|
1073
|
+
template.steps = {
|
|
1074
|
+
':original': {
|
|
1075
|
+
robot: '/upload/handle',
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return JSON.stringify(template, null, indent);
|
|
1080
|
+
}
|
|
1081
|
+
function fixMissingOriginalStorage(content) {
|
|
1082
|
+
const [templateError, template, indent] = parseSafeTemplate(content);
|
|
1083
|
+
if (templateError) {
|
|
1084
|
+
return content;
|
|
1085
|
+
}
|
|
1086
|
+
// Find the storage step
|
|
1087
|
+
for (const [, step] of entries(template.steps)) {
|
|
1088
|
+
if (step.robot.endsWith('/store')) {
|
|
1089
|
+
// Add :original to the use array if it's not already there
|
|
1090
|
+
const updatedStep = addUseReference(step, ':original', { leading: true });
|
|
1091
|
+
Object.assign(step, updatedStep);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return JSON.stringify(template, null, indent);
|
|
1095
|
+
}
|
|
1096
|
+
// Add new fix function
|
|
1097
|
+
function fixSmartCdnInputField(content, fixData) {
|
|
1098
|
+
const [templateError, template, indent] = parseSafeTemplate(content);
|
|
1099
|
+
if (templateError) {
|
|
1100
|
+
return content;
|
|
1101
|
+
}
|
|
1102
|
+
const step = template.steps?.[fixData.stepName];
|
|
1103
|
+
if (!step || step.robot !== '/http/import') {
|
|
1104
|
+
return content;
|
|
1105
|
+
}
|
|
1106
|
+
// Type assertion since we know this is an http-import step
|
|
1107
|
+
const httpImportStep = step;
|
|
1108
|
+
// Only modify the url field in the specified step
|
|
1109
|
+
httpImportStep.url = 'https://demos.transloadit.com/${fields.input}';
|
|
1110
|
+
// Stringify back with the same indentation
|
|
1111
|
+
return JSON.stringify(template, null, indent);
|
|
1112
|
+
}
|
|
1113
|
+
export function applyFix(content, fixId, fixData) {
|
|
1114
|
+
switch (fixId) {
|
|
1115
|
+
case 'fix-wrong-stack-version':
|
|
1116
|
+
return fixWrongStackVersion(content, fixWrongStackVersionSchema.parse(fixData));
|
|
1117
|
+
case 'fix-missing-use':
|
|
1118
|
+
return fixMissingUse(content, fixMissingUseSchema.parse(fixData));
|
|
1119
|
+
case 'fix-duplicate-key-in-step':
|
|
1120
|
+
return fixDuplicateKeyInStep(content, fixDuplicateKeyInStepSchema.parse(fixData));
|
|
1121
|
+
case 'fix-missing-input':
|
|
1122
|
+
fixMissingInputSchema.parse(fixData);
|
|
1123
|
+
return fixMissingInput(content);
|
|
1124
|
+
case 'fix-missing-steps':
|
|
1125
|
+
fixMissingStepsSchema.parse(fixData);
|
|
1126
|
+
return fixMissingSteps(content);
|
|
1127
|
+
case 'fix-invalid-steps-type':
|
|
1128
|
+
fixInvalidStepsTypeSchema.parse(fixData);
|
|
1129
|
+
return fixInvalidStepsType(content);
|
|
1130
|
+
case 'fix-empty-steps':
|
|
1131
|
+
fixEmptyStepsSchema.parse(fixData);
|
|
1132
|
+
return fixEmptySteps(content);
|
|
1133
|
+
case 'fix-missing-original-storage':
|
|
1134
|
+
fixMissingOriginalStorageSchema.parse(fixData);
|
|
1135
|
+
return fixMissingOriginalStorage(content);
|
|
1136
|
+
case 'fix-smart-cdn-input-field':
|
|
1137
|
+
return fixSmartCdnInputField(content, fixSmartCdnInputFieldSchema.parse(fixData));
|
|
1138
|
+
default:
|
|
1139
|
+
throw new Error(`Unknown fixId: ${fixId}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
//# sourceMappingURL=assembly-linter.js.map
|