mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 21:07:46 +00:00
[cli] Add vercel.json validation to vercel build (#8622)
We were doing this validation in `vercel dev` but not `vercel build`. This PR adds `vercel.json` validation to `vercel build` too. Note I am calling this a patch because invalid `vercel.json` was already failing when passed to the API so this allows a nice error message earlier in the process.
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
MergeRoutesProps,
|
||||
Route,
|
||||
} from '@vercel/routing-utils';
|
||||
import { fileNameSymbol } from '@vercel/client';
|
||||
import type { VercelConfig } from '@vercel/client';
|
||||
|
||||
import pull from './pull';
|
||||
@@ -54,6 +55,7 @@ import { importBuilders } from '../util/build/import-builders';
|
||||
import { initCorepack, cleanupCorepack } from '../util/build/corepack';
|
||||
import { sortBuilders } from '../util/build/sort-builders';
|
||||
import { toEnumerableError } from '../util/error';
|
||||
import { validateConfig } from '../util/validate-config';
|
||||
|
||||
type BuildResult = BuildResultV2 | BuildResultV3;
|
||||
|
||||
@@ -269,19 +271,33 @@ async function doBuild(
|
||||
const { output } = client;
|
||||
const workPath = join(cwd, project.settings.rootDirectory || '.');
|
||||
|
||||
// Load `package.json` and `vercel.json` files
|
||||
const [pkg, vercelConfig] = await Promise.all([
|
||||
const [pkg, vercelConfig, nowConfig] = await Promise.all([
|
||||
readJSONFile<PackageJson>(join(workPath, 'package.json')),
|
||||
readJSONFile<VercelConfig>(join(workPath, 'vercel.json')).then(
|
||||
config => config || readJSONFile<VercelConfig>(join(workPath, 'now.json'))
|
||||
),
|
||||
readJSONFile<VercelConfig>(join(workPath, 'vercel.json')),
|
||||
readJSONFile<VercelConfig>(join(workPath, 'now.json')),
|
||||
]);
|
||||
|
||||
if (pkg instanceof CantParseJSONFile) throw pkg;
|
||||
if (vercelConfig instanceof CantParseJSONFile) throw vercelConfig;
|
||||
if (nowConfig instanceof CantParseJSONFile) throw nowConfig;
|
||||
|
||||
if (vercelConfig) {
|
||||
vercelConfig[fileNameSymbol] = 'vercel.json';
|
||||
} else if (nowConfig) {
|
||||
nowConfig[fileNameSymbol] = 'now.json';
|
||||
}
|
||||
|
||||
const localConfig = vercelConfig || nowConfig || {};
|
||||
const validateError = validateConfig(localConfig);
|
||||
|
||||
if (validateError) {
|
||||
output.prettyError(validateError);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const projectSettings = {
|
||||
...project.settings,
|
||||
...pickOverrides(vercelConfig || {}),
|
||||
...pickOverrides(localConfig),
|
||||
};
|
||||
|
||||
// Get a list of source files
|
||||
@@ -289,12 +305,12 @@ async function doBuild(
|
||||
normalizePath(relative(workPath, f))
|
||||
);
|
||||
|
||||
const routesResult = getTransformedRoutes(vercelConfig || {});
|
||||
const routesResult = getTransformedRoutes(localConfig);
|
||||
if (routesResult.error) {
|
||||
throw routesResult.error;
|
||||
}
|
||||
|
||||
if (vercelConfig?.builds && vercelConfig.functions) {
|
||||
if (localConfig.builds && localConfig.functions) {
|
||||
throw new NowBuildError({
|
||||
code: 'bad_request',
|
||||
message:
|
||||
@@ -303,7 +319,7 @@ async function doBuild(
|
||||
});
|
||||
}
|
||||
|
||||
let builds = vercelConfig?.builds || [];
|
||||
let builds = localConfig.builds || [];
|
||||
let zeroConfigRoutes: Route[] = [];
|
||||
let isZeroConfig = false;
|
||||
|
||||
@@ -318,7 +334,7 @@ async function doBuild(
|
||||
|
||||
// Detect the Vercel Builders that will need to be invoked
|
||||
const detectedBuilders = await detectBuilders(files, pkg, {
|
||||
...vercelConfig,
|
||||
...localConfig,
|
||||
projectSettings,
|
||||
ignoreBuildScript: true,
|
||||
featHandleMiss: true,
|
||||
@@ -466,7 +482,7 @@ async function doBuild(
|
||||
build,
|
||||
builder,
|
||||
builderPkg,
|
||||
vercelConfig
|
||||
localConfig
|
||||
).then(
|
||||
override => {
|
||||
if (override) overrides.push(override);
|
||||
@@ -555,150 +571,7 @@ async function doBuild(
|
||||
builds: builderRoutes,
|
||||
});
|
||||
|
||||
const images = vercelConfig?.images
|
||||
if (images) {
|
||||
if (typeof images !== 'object') {
|
||||
throw new Error(
|
||||
`vercel.json "images" should be an object received ${typeof images}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(images.domains)) {
|
||||
throw new Error(
|
||||
`vercel.json "images.domains" should be an Array received ${typeof images.domains}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (images.domains.length > 50) {
|
||||
throw new Error(
|
||||
`vercel.json "images.domains" exceeds length of 50 received length (${images.domains.length}).`
|
||||
);
|
||||
}
|
||||
|
||||
const invalidImageDomains = images.domains.filter(
|
||||
(d: unknown) => typeof d !== 'string'
|
||||
);
|
||||
if (invalidImageDomains.length > 0) {
|
||||
throw new Error(
|
||||
`vercel.json "images.domains" should be an Array of strings received invalid values (${invalidImageDomains.join(
|
||||
', '
|
||||
)}).`
|
||||
);
|
||||
}
|
||||
|
||||
if (images.remotePatterns) {
|
||||
if (!Array.isArray(images.remotePatterns)) {
|
||||
throw new Error(
|
||||
`vercel.json "images.remotePatterns" should be an Array received ${typeof images.remotePatterns}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (images.remotePatterns.length > 50) {
|
||||
throw new Error(
|
||||
`vercel.json "images.remotePatterns" exceeds length of 50, received length (${images.remotePatterns.length}).`
|
||||
);
|
||||
}
|
||||
|
||||
const validProps = new Set(['protocol', 'hostname', 'pathname', 'port']);
|
||||
const requiredProps = ['hostname'];
|
||||
const invalidPatterns = images.remotePatterns.filter(
|
||||
(d: unknown) =>
|
||||
!d ||
|
||||
typeof d !== 'object' ||
|
||||
Object.entries(d).some(
|
||||
([k, v]) => !validProps.has(k) || typeof v !== 'string'
|
||||
) ||
|
||||
requiredProps.some(k => !(k in d))
|
||||
);
|
||||
if (invalidPatterns.length > 0) {
|
||||
throw new Error(
|
||||
`vercel.json "images.remotePatterns" received invalid values:\n${invalidPatterns
|
||||
.map(item => JSON.stringify(item))
|
||||
.join(
|
||||
'\n'
|
||||
)}\n\nremotePatterns value must follow format { protocol: 'https', hostname: 'example.com', port: '', pathname: '/imgs/**' }.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(images.sizes)) {
|
||||
throw new Error(
|
||||
`vercel.json "images.sizes" should be an Array received ${typeof images.sizes}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (images.sizes.length < 1 || images.sizes.length > 50) {
|
||||
throw new Error(
|
||||
`vercel.json "images.sizes" should be an Array of length between 1 to 50 received length (${images.sizes.length}).`
|
||||
);
|
||||
}
|
||||
|
||||
const invalidImageSizes = images.sizes.filter((d: unknown) => {
|
||||
return typeof d !== 'number' || d < 1 || d > 10000;
|
||||
});
|
||||
if (invalidImageSizes.length > 0) {
|
||||
throw new Error(
|
||||
`vercel.json "images.sizes" should be an Array of numbers that are between 1 and 10000, received invalid values (${invalidImageSizes.join(
|
||||
', '
|
||||
)}).`
|
||||
);
|
||||
}
|
||||
|
||||
if (images.minimumCacheTTL) {
|
||||
if (
|
||||
!Number.isInteger(images.minimumCacheTTL) ||
|
||||
images.minimumCacheTTL < 0
|
||||
) {
|
||||
throw new Error(
|
||||
`vercel.json "images.minimumCacheTTL" should be an integer 0 or more received (${images.minimumCacheTTL}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (images.formats) {
|
||||
if (!Array.isArray(images.formats)) {
|
||||
throw new Error(
|
||||
`vercel.json "images.formats" should be an Array received ${typeof images.formats}.`
|
||||
);
|
||||
}
|
||||
if (images.formats.length < 1 || images.formats.length > 2) {
|
||||
throw new Error(
|
||||
`vercel.json "images.formats" must be length 1 or 2, received length (${images.formats.length}).`
|
||||
);
|
||||
}
|
||||
|
||||
const invalid = images.formats.filter(f => {
|
||||
return f !== 'image/avif' && f !== 'image/webp';
|
||||
});
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`vercel.json "images.formats" should be an Array of mime type strings, received invalid values (${invalid.join(
|
||||
', '
|
||||
)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof images.dangerouslyAllowSVG !== 'undefined' &&
|
||||
typeof images.dangerouslyAllowSVG !== 'boolean'
|
||||
) {
|
||||
throw new Error(
|
||||
`vercel.json "images.dangerouslyAllowSVG" should be a boolean received (${images.dangerouslyAllowSVG}).`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof images.contentSecurityPolicy !== 'undefined' &&
|
||||
typeof images.contentSecurityPolicy !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`vercel.json "images.contentSecurityPolicy" should be a string received ${images.contentSecurityPolicy}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mergedImages = mergeImages(images, buildResults.values());
|
||||
const mergedImages = mergeImages(localConfig.images, buildResults.values());
|
||||
const mergedWildcard = mergeWildcard(buildResults.values());
|
||||
const mergedOverrides: Record<string, PathOverride> =
|
||||
overrides.length > 0 ? Object.assign({}, ...overrides) : undefined;
|
||||
|
||||
@@ -57,7 +57,7 @@ import { MissingDotenvVarsError } from '../errors-ts';
|
||||
import cliPkg from '../pkg';
|
||||
import { getVercelDirectory } from '../projects/link';
|
||||
import { staticFiles as getFiles } from '../get-files';
|
||||
import { validateConfig } from './validate';
|
||||
import { validateConfig } from '../validate-config';
|
||||
import { devRouter, getRoutesTypes } from './router';
|
||||
import getMimeType from './mime-type';
|
||||
import { executeBuild, getBuildMatches, shutdownBuilder } from './builder';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
rewritesSchema,
|
||||
trailingSlashSchema,
|
||||
} from '@vercel/routing-utils';
|
||||
import { VercelConfig } from './types';
|
||||
import { VercelConfig } from './dev/types';
|
||||
import {
|
||||
functionsSchema,
|
||||
buildsSchema,
|
||||
@@ -16,6 +16,83 @@ import {
|
||||
} from '@vercel/build-utils';
|
||||
import { fileNameSymbol } from '@vercel/client';
|
||||
|
||||
const imagesSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['sizes'],
|
||||
properties: {
|
||||
contentSecurityPolicy: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
},
|
||||
dangerouslyAllowSVG: {
|
||||
type: 'boolean',
|
||||
},
|
||||
domains: {
|
||||
type: 'array',
|
||||
minItems: 0,
|
||||
maxItems: 50,
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
},
|
||||
},
|
||||
formats: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 4,
|
||||
items: {
|
||||
enum: ['image/avif', 'image/webp', 'image/jpeg', 'image/png'],
|
||||
},
|
||||
},
|
||||
minimumCacheTTL: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 315360000,
|
||||
},
|
||||
remotePatterns: {
|
||||
type: 'array',
|
||||
minItems: 0,
|
||||
maxItems: 50,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['hostname'],
|
||||
properties: {
|
||||
protocol: {
|
||||
enum: ['http', 'https'],
|
||||
},
|
||||
hostname: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
},
|
||||
port: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
},
|
||||
pathname: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 50,
|
||||
items: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const vercelConfigSchema = {
|
||||
type: 'object',
|
||||
// These are not all possibilities because `vc dev`
|
||||
@@ -30,6 +107,7 @@ const vercelConfigSchema = {
|
||||
rewrites: rewritesSchema,
|
||||
trailingSlash: trailingSlashSchema,
|
||||
functions: functionsSchema,
|
||||
images: imagesSchema,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"sizes": [256, 384, 600, 1000],
|
||||
"domains": [],
|
||||
"minimumCacheTTL": 60,
|
||||
"formats": ["image/webp", "image/avif"]
|
||||
"formats": ["image/avif", "image/webp"]
|
||||
}
|
||||
}
|
||||
|
||||
7
packages/cli/test/fixtures/unit/commands/build/invalid-rewrites/.vercel/project.json
vendored
Normal file
7
packages/cli/test/fixtures/unit/commands/build/invalid-rewrites/.vercel/project.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"orgId": ".",
|
||||
"projectId": ".",
|
||||
"settings": {
|
||||
"framework": null
|
||||
}
|
||||
}
|
||||
1
packages/cli/test/fixtures/unit/commands/build/invalid-rewrites/index.html
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/build/invalid-rewrites/index.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<h1>Vercel</h1>
|
||||
16
packages/cli/test/fixtures/unit/commands/build/invalid-rewrites/vercel.json
vendored
Normal file
16
packages/cli/test/fixtures/unit/commands/build/invalid-rewrites/vercel.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/one",
|
||||
"destination": "/api/one"
|
||||
},
|
||||
{
|
||||
"source": "/two",
|
||||
"destination": "/api/two"
|
||||
},
|
||||
{
|
||||
"src": "/three",
|
||||
"dest": "/api/three"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -936,7 +936,7 @@ describe('build', () => {
|
||||
sizes: [256, 384, 600, 1000],
|
||||
domains: [],
|
||||
minimumCacheTTL: 60,
|
||||
formats: ['image/webp', 'image/avif'],
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -945,6 +945,23 @@ describe('build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail with invalid "rewrites" configuration from `vercel.json`', async () => {
|
||||
const cwd = fixture('invalid-rewrites');
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
const exitCode = await build(client);
|
||||
expect(exitCode).toEqual(1);
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: Invalid vercel.json - `rewrites[2]` should NOT have additional property `src`. Did you mean `source`?' +
|
||||
'\n' +
|
||||
'View Documentation: https://vercel.com/docs/configuration#project/rewrites'
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
delete process.env.__VERCEL_BUILD_RUNNING;
|
||||
}
|
||||
});
|
||||
|
||||
describe('should find packages with different main/module/browser keys', function () {
|
||||
let output: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { validateConfig } from '../../../../src/util/dev/validate';
|
||||
import { validateConfig } from '../../../../src/util/validate-config';
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should not error with empty config', async () => {
|
||||
|
||||
Reference in New Issue
Block a user