[static-build] Add support for Build Output v3 detection (#7669)

Adds detection logic for a framework / build script outputting Build Output v3
format to the filesystem. In this case, `static-build` will simply stop processing
after the Build Command since deserialization happens in the build-container side
of things (this is different compared to the v1 output which gets handled in this
Builder. The reason for that is because the v3 output matches what `vc build`
outputs vs. v1 which is a different format).
This commit is contained in:
Nathan Rajlich
2022-04-12 09:09:50 -07:00
committed by GitHub
parent d62461d952
commit 58f479c603
13 changed files with 219 additions and 21 deletions

View File

@@ -356,7 +356,29 @@ export interface Images {
formats?: ImageFormat[]; formats?: ImageFormat[];
} }
export interface BuildResultV2 { /**
* If a Builder ends up creating filesystem outputs conforming to
* the Build Output API, then the Builder should return this type.
*/
export interface BuildResultBuildOutput {
/**
* Version number of the Build Output API that was created.
* Currently only `3` is a valid value.
* @example 3
*/
buildOutputVersion: 3;
/**
* Filesystem path to the Build Output directory.
* @example "/path/to/.vercel/output"
*/
buildOutputPath: string;
}
/**
* When a Builder implements `version: 2`, the `build()` function is expected
* to return this type.
*/
export interface BuildResultV2Typical {
// TODO: use proper `Route` type from `routing-utils` (perhaps move types to a common package) // TODO: use proper `Route` type from `routing-utils` (perhaps move types to a common package)
routes?: any[]; routes?: any[];
images?: Images; images?: Images;
@@ -369,6 +391,8 @@ export interface BuildResultV2 {
}>; }>;
} }
export type BuildResultV2 = BuildResultV2Typical | BuildResultBuildOutput;
export interface BuildResultV3 { export interface BuildResultV3 {
output: Lambda; output: Lambda;
} }

View File

@@ -14,13 +14,24 @@
}, },
"scripts": { "scripts": {
"build": "node build", "build": "node build",
"test-unit": "jest --env node --verbose --runInBand --bail test/unit.test.js", "test-unit": "jest --env node --verbose --bail test/build.test.ts test/prepare-cache.test.ts",
"test-integration-once": "jest --env node --verbose --runInBand --bail test/integration.test.js", "test-integration-once": "jest --env node --verbose --runInBand --bail test/integration.test.js",
"prepublishOnly": "node build" "prepublishOnly": "node build"
}, },
"jest": {
"preset": "ts-jest/presets/default",
"testEnvironment": "node",
"globals": {
"ts-jest": {
"diagnostics": true,
"isolatedModules": true
}
}
},
"devDependencies": { "devDependencies": {
"@types/aws-lambda": "8.10.64", "@types/aws-lambda": "8.10.64",
"@types/cross-spawn": "6.0.0", "@types/cross-spawn": "6.0.0",
"@types/jest": "27.4.1",
"@types/ms": "0.7.31", "@types/ms": "0.7.31",
"@types/node-fetch": "2.5.4", "@types/node-fetch": "2.5.4",
"@types/promise-timeout": "1.3.0", "@types/promise-timeout": "1.3.0",

View File

@@ -4,7 +4,7 @@ import fetch from 'node-fetch';
import getPort from 'get-port'; import getPort from 'get-port';
import isPortReachable from 'is-port-reachable'; import isPortReachable from 'is-port-reachable';
import frameworks, { Framework } from '@vercel/frameworks'; import frameworks, { Framework } from '@vercel/frameworks';
import { ChildProcess, SpawnOptions } from 'child_process'; import type { ChildProcess, SpawnOptions } from 'child_process';
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
import { cpus } from 'os'; import { cpus } from 'os';
import { import {
@@ -30,14 +30,12 @@ import {
NowBuildError, NowBuildError,
scanParentDirs, scanParentDirs,
} from '@vercel/build-utils'; } from '@vercel/build-utils';
import { Route, Source } from '@vercel/routing-utils'; import type { Route, Source } from '@vercel/routing-utils';
import { import * as BuildOutputV1 from './utils/build-output-v1';
readBuildOutputDirectory, import * as BuildOutputV3 from './utils/build-output-v3';
readBuildOutputConfig,
} from './utils/read-build-output';
import * as GatsbyUtils from './utils/gatsby'; import * as GatsbyUtils from './utils/gatsby';
import * as NuxtUtils from './utils/nuxt'; import * as NuxtUtils from './utils/nuxt';
import { ImagesConfig, BuildConfig } from './utils/_shared'; import type { ImagesConfig, BuildConfig } from './utils/_shared';
const sleep = (n: number) => new Promise(resolve => setTimeout(resolve, n)); const sleep = (n: number) => new Promise(resolve => setTimeout(resolve, n));
@@ -284,11 +282,8 @@ export const build: BuildV2 = async ({
); );
const pkg = getPkg(entrypoint, workPath); const pkg = getPkg(entrypoint, workPath);
const devScript = pkg ? getScriptName(pkg, 'dev', config) : null; const devScript = pkg ? getScriptName(pkg, 'dev', config) : null;
const framework = getFramework(config, pkg); const framework = getFramework(config, pkg);
const devCommand = getCommand('dev', pkg, config, framework); const devCommand = getCommand('dev', pkg, config, framework);
const buildCommand = getCommand('build', pkg, config, framework); const buildCommand = getCommand('build', pkg, config, framework);
const installCommand = getCommand('install', pkg, config, framework); const installCommand = getCommand('install', pkg, config, framework);
@@ -637,6 +632,30 @@ export const build: BuildV2 = async ({
const outputDirPrefix = path.join(workPath, path.dirname(entrypoint)); const outputDirPrefix = path.join(workPath, path.dirname(entrypoint));
// If the Build Command or Framework output files according to the
// Build Output v3 API, then stop processing here in `static-build`
// since the output is already in its final form.
const buildOutputPath = await BuildOutputV3.getBuildOutputDirectory(
outputDirPrefix
);
if (buildOutputPath) {
// Ensure that `vercel build` is being used for this Deployment
if (!meta.cliVersion) {
let buildCommandName: string;
if (buildCommand) buildCommandName = `"${buildCommand}"`;
else if (framework) buildCommandName = framework.name;
else buildCommandName = 'the "build" script';
throw new Error(
`Detected Build Output v3 from ${buildCommandName}, but this Deployment is not using \`vercel build\`.\nPlease set the \`ENABLE_VC_BUILD=1\` environment variable.`
);
}
return {
buildOutputVersion: 3,
buildOutputPath,
};
}
if (framework) { if (framework) {
const outputDirName = config.outputDirectory const outputDirName = config.outputDirectory
? config.outputDirectory ? config.outputDirectory
@@ -656,7 +675,7 @@ export const build: BuildV2 = async ({
} }
} }
const extraOutputs = await readBuildOutputDirectory({ const extraOutputs = await BuildOutputV1.readBuildOutputDirectory({
workPath, workPath,
nodeVersion, nodeVersion,
}); });
@@ -750,7 +769,7 @@ export const prepareCache: PrepareCache = async ({
const cacheFiles: Files = {}; const cacheFiles: Files = {};
// File System API v1 cache files // File System API v1 cache files
const buildConfig = await readBuildOutputConfig<BuildConfig>({ const buildConfig = await BuildOutputV1.readBuildOutputConfig<BuildConfig>({
workPath, workPath,
configFileName: 'build.json', configFileName: 'build.json',
}); });

View File

@@ -0,0 +1,23 @@
import { join } from 'path';
import { promises as fs } from 'fs';
const BUILD_OUTPUT_DIR = '.vercel/output';
/**
* Returns the path to the Build Output v3 directory when the
* `config.json` file was created by the framework / build script,
* or `undefined` if the framework did not create the v3 output.
*/
export async function getBuildOutputDirectory(
path: string
): Promise<string | undefined> {
try {
const outputDir = join(path, BUILD_OUTPUT_DIR);
const configPath = join(outputDir, 'config.json');
await fs.stat(configPath);
return outputDir;
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
return undefined;
}

View File

@@ -30,9 +30,7 @@ export async function injectVercelAnalyticsPlugin(dir: string) {
`Injecting Gatsby.js analytics plugin "${gatsbyPluginPackageName}" to \`${gatsbyConfigPath}\`` `Injecting Gatsby.js analytics plugin "${gatsbyPluginPackageName}" to \`${gatsbyConfigPath}\``
); );
const pkgJson: DeepWriteable<PackageJson> = (await readPackageJson( const pkgJson = (await readPackageJson(dir)) as DeepWriteable<PackageJson>;
dir
)) as DeepWriteable<PackageJson>;
if (!pkgJson.dependencies) { if (!pkgJson.dependencies) {
pkgJson.dependencies = {}; pkgJson.dependencies = {};
} }

View File

@@ -0,0 +1,7 @@
const fs = require('fs');
fs.mkdirSync('.vercel/output/static', { recursive: true });
fs.writeFileSync('.vercel/output/config.json', '{}');
fs.writeFileSync(
'.vercel/output/static/index.html',
'<h1>Build Output API</h1>'
);

View File

@@ -0,0 +1,7 @@
{
"name": "09-build-output-v3",
"private": true,
"scripts": {
"build": "node build.js"
}
}

View File

@@ -0,0 +1,54 @@
import path from 'path';
import { build } from '../src';
describe('build()', () => {
it('should detect Builder Output v3', async () => {
const workPath = path.join(
__dirname,
'build-fixtures',
'09-build-output-v3'
);
const buildResult = await build({
files: {},
entrypoint: 'package.json',
workPath,
config: {},
meta: {
skipDownload: true,
cliVersion: '0.0.0',
},
});
if ('output' in buildResult) {
throw new Error('Unexpected `output` in build result');
}
expect(buildResult.buildOutputVersion).toEqual(3);
expect(buildResult.buildOutputPath).toEqual(
path.join(workPath, '.vercel/output')
);
});
it('should throw an Error with Builder Output v3 without `vercel build`', async () => {
let err;
const workPath = path.join(
__dirname,
'build-fixtures',
'09-build-output-v3'
);
try {
await build({
files: {},
entrypoint: 'package.json',
workPath,
config: {},
meta: {
skipDownload: true,
},
});
} catch (_err: any) {
err = _err;
}
expect(err.message).toEqual(
`Detected Build Output v3 from the "build" script, but this Deployment is not using \`vercel build\`.\nPlease set the \`ENABLE_VC_BUILD=1\` environment variable.`
);
});
});

View File

@@ -1,12 +1,13 @@
const { prepareCache } = require('../dist'); import path from 'path';
const path = require('path'); import { prepareCache } from '../src';
describe('prepareCache', () => { describe('prepareCache()', () => {
test('should cache node_modules and .shadow-cljs', async () => { test('should cache node_modules and .shadow-cljs', async () => {
const files = await prepareCache({ const files = await prepareCache({
config: { zeroConfig: true }, config: { zeroConfig: true },
workPath: path.resolve(__dirname, './cache-fixtures/default'), workPath: path.resolve(__dirname, './cache-fixtures/default'),
entrypoint: 'index.js', entrypoint: 'index.js',
files: {},
}); });
expect(files['node_modules/file']).toBeDefined(); expect(files['node_modules/file']).toBeDefined();
@@ -19,6 +20,7 @@ describe('prepareCache', () => {
config: { zeroConfig: true }, config: { zeroConfig: true },
workPath: path.resolve(__dirname, './cache-fixtures/withCacheConfig'), workPath: path.resolve(__dirname, './cache-fixtures/withCacheConfig'),
entrypoint: 'index.js', entrypoint: 'index.js',
files: {},
}); });
expect(files['node_modules/file']).toBeUndefined(); expect(files['node_modules/file']).toBeUndefined();
@@ -32,6 +34,7 @@ describe('prepareCache', () => {
config: { zeroConfig: true }, config: { zeroConfig: true },
workPath: path.resolve(__dirname, './cache-fixtures/gatsby'), workPath: path.resolve(__dirname, './cache-fixtures/gatsby'),
entrypoint: 'package.json', entrypoint: 'package.json',
files: {},
}); });
expect(files['node_modules/file2']).toBeDefined(); expect(files['node_modules/file2']).toBeDefined();
@@ -45,6 +48,7 @@ describe('prepareCache', () => {
config: { zeroConfig: true, framework: 'jekyll' }, config: { zeroConfig: true, framework: 'jekyll' },
workPath: path.resolve(__dirname, './cache-fixtures/jekyll'), workPath: path.resolve(__dirname, './cache-fixtures/jekyll'),
entrypoint: 'Gemfile', entrypoint: 'Gemfile',
files: {},
}); });
expect(files['vendor/bundle/b1']).toBeDefined(); expect(files['vendor/bundle/b1']).toBeDefined();

View File

@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["*.test.ts"]
}

View File

@@ -11,7 +11,7 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"outDir": "dist", "outDir": "dist",
"types": ["node"], "types": ["node", "jest"],
"strict": true, "strict": true,
"target": "es2018" "target": "es2018"
}, },

View File

@@ -2180,6 +2180,14 @@
jest-diff "^27.0.0" jest-diff "^27.0.0"
pretty-format "^27.0.0" pretty-format "^27.0.0"
"@types/jest@27.4.1":
version "27.4.1"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.1.tgz#185cbe2926eaaf9662d340cc02e548ce9e11ab6d"
integrity sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==
dependencies:
jest-matcher-utils "^27.0.0"
pretty-format "^27.0.0"
"@types/js-yaml@3.12.1": "@types/js-yaml@3.12.1":
version "3.12.1" version "3.12.1"
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.1.tgz#5c6f4a1eabca84792fbd916f0cb40847f123c656" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.1.tgz#5c6f4a1eabca84792fbd916f0cb40847f123c656"
@@ -4677,6 +4685,11 @@ diff-sequences@^27.0.6:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.0.6.tgz#3305cb2e55a033924054695cc66019fd7f8e5723" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.0.6.tgz#3305cb2e55a033924054695cc66019fd7f8e5723"
integrity sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ== integrity sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ==
diff-sequences@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327"
integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==
diff@^4.0.1: diff@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
@@ -7160,6 +7173,16 @@ jest-diff@^27.3.1:
jest-get-type "^27.3.1" jest-get-type "^27.3.1"
pretty-format "^27.3.1" pretty-format "^27.3.1"
jest-diff@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==
dependencies:
chalk "^4.0.0"
diff-sequences "^27.5.1"
jest-get-type "^27.5.1"
pretty-format "^27.5.1"
jest-docblock@^27.0.6: jest-docblock@^27.0.6:
version "27.0.6" version "27.0.6"
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.0.6.tgz#cc78266acf7fe693ca462cbbda0ea4e639e4e5f3" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.0.6.tgz#cc78266acf7fe693ca462cbbda0ea4e639e4e5f3"
@@ -7213,6 +7236,11 @@ jest-get-type@^27.3.1:
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.3.1.tgz#a8a2b0a12b50169773099eee60a0e6dd11423eff" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.3.1.tgz#a8a2b0a12b50169773099eee60a0e6dd11423eff"
integrity sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg== integrity sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg==
jest-get-type@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1"
integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==
jest-haste-map@^27.3.1: jest-haste-map@^27.3.1:
version "27.3.1" version "27.3.1"
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.3.1.tgz#7656fbd64bf48bda904e759fc9d93e2c807353ee" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.3.1.tgz#7656fbd64bf48bda904e759fc9d93e2c807353ee"
@@ -7265,6 +7293,16 @@ jest-leak-detector@^27.3.1:
jest-get-type "^27.3.1" jest-get-type "^27.3.1"
pretty-format "^27.3.1" pretty-format "^27.3.1"
jest-matcher-utils@^27.0.0:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab"
integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==
dependencies:
chalk "^4.0.0"
jest-diff "^27.5.1"
jest-get-type "^27.5.1"
pretty-format "^27.5.1"
jest-matcher-utils@^27.3.1: jest-matcher-utils@^27.3.1:
version "27.3.1" version "27.3.1"
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.3.1.tgz#257ad61e54a6d4044e080d85dbdc4a08811e9c1c" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.3.1.tgz#257ad61e54a6d4044e080d85dbdc4a08811e9c1c"
@@ -9540,6 +9578,15 @@ pretty-format@^27.3.1:
ansi-styles "^5.0.0" ansi-styles "^5.0.0"
react-is "^17.0.1" react-is "^17.0.1"
pretty-format@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"
integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==
dependencies:
ansi-regex "^5.0.1"
ansi-styles "^5.0.0"
react-is "^17.0.1"
pretty-ms@^5.0.0: pretty-ms@^5.0.0:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-5.1.0.tgz#b906bdd1ec9e9799995c372e2b1c34f073f95384" resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-5.1.0.tgz#b906bdd1ec9e9799995c372e2b1c34f073f95384"