[static-build] Support subset of Build Output API v2 (#7808)

* Revert "Revert "[static-build] Support subset of Build Output API v2" (#7803)"

This reverts commit dfb6ef949b.

* more specific v2 detection

* use nuxt@3.0.0-rc.3 to make sure the incident is resolved

* set test timeout to see if this works in CI

* update CI node version to 14

* add node_modules because it was taking too long to install in CI

* remove timeout

* remove node modules

* remove the nuxt@3 dependency

* finish update to node 14

* blank yarn.lock

* revert node version update

* fix revert

* remove newline
This commit is contained in:
Sean Massa
2022-05-20 14:27:14 -05:00
committed by GitHub
parent 3a8b8aa03a
commit 3aa2fbbb53
32 changed files with 749 additions and 74 deletions

View File

@@ -2,6 +2,7 @@ dist
worker
.solid
.vercel
.output
# dependencies
/node_modules

View File

@@ -32,6 +32,7 @@
"devDependencies": {
"@types/aws-lambda": "8.10.64",
"@types/cross-spawn": "6.0.0",
"@types/fs-extra": "9.0.13",
"@types/jest": "27.4.1",
"@types/ms": "0.7.31",
"@types/node-fetch": "2.5.4",

View File

@@ -32,6 +32,7 @@ import {
} from '@vercel/build-utils';
import type { Route, Source } from '@vercel/routing-utils';
import * as BuildOutputV1 from './utils/build-output-v1';
import * as BuildOutputV2 from './utils/build-output-v2';
import * as BuildOutputV3 from './utils/build-output-v3';
import * as GatsbyUtils from './utils/gatsby';
import * as NuxtUtils from './utils/nuxt';
@@ -261,6 +262,37 @@ async function fetchBinary(url: string, framework: string, version: string) {
});
}
async function getUpdatedDistPath(
framework: Framework | undefined,
outputDirPrefix: string,
entrypointDir: string,
distPath: string,
config: Config
): Promise<string | undefined> {
if (framework) {
const outputDirName = config.outputDirectory
? config.outputDirectory
: await framework.getOutputDirName(outputDirPrefix);
return path.join(outputDirPrefix, outputDirName);
}
if (!config || !config.distDir) {
// Select either `dist` or `public` as directory
const publicPath = path.join(entrypointDir, 'public');
if (
!existsSync(distPath) &&
existsSync(publicPath) &&
statSync(publicPath).isDirectory()
) {
return publicPath;
}
}
return undefined;
}
export const build: BuildV2 = async ({
files,
entrypoint,
@@ -629,48 +661,36 @@ export const build: BuildV2 = async ({
}
const outputDirPrefix = path.join(workPath, path.dirname(entrypoint));
distPath =
(await getUpdatedDistPath(
framework,
outputDirPrefix,
entrypointDir,
distPath,
config
)) || distPath;
// 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(
const buildOutputPathV3 = await BuildOutputV3.getBuildOutputDirectory(
outputDirPrefix
);
if (buildOutputPath) {
if (buildOutputPathV3) {
// 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,
};
return BuildOutputV3.createBuildOutput(
meta,
buildCommand,
buildOutputPathV3,
framework
);
}
if (framework) {
const outputDirName = config.outputDirectory
? config.outputDirectory
: await framework.getOutputDirName(outputDirPrefix);
distPath = path.join(outputDirPrefix, outputDirName);
} else if (!config || !config.distDir) {
// Select either `dist` or `public` as directory
const publicPath = path.join(entrypointDir, 'public');
if (
!existsSync(distPath) &&
existsSync(publicPath) &&
statSync(publicPath).isDirectory()
) {
distPath = publicPath;
}
const buildOutputPathV2 = await BuildOutputV2.getBuildOutputDirectory(
outputDirPrefix
);
if (buildOutputPathV2) {
return await BuildOutputV2.createBuildOutput(workPath);
}
const extraOutputs = await BuildOutputV1.readBuildOutputDirectory({

View File

@@ -0,0 +1,184 @@
import path from 'path';
import { pathExists, readJson, appendFile } from 'fs-extra';
import { Route } from '@vercel/routing-utils';
import {
Files,
FileFsRef,
debug,
glob,
EdgeFunction,
BuildResultV2,
} from '@vercel/build-utils';
import { isObjectEmpty } from './_shared';
const BUILD_OUTPUT_DIR = '.output';
const BRIDGE_MIDDLEWARE_V2_TO_V3 = `
// appended to convert v2 middleware to v3 middleware
export default async (request) => {
const { response } = await _ENTRIES['middleware_pages/_middleware'].default({ request });
return response;
}
`;
const CONFIG_FILES = [
'build-manifest.json',
'functions-manifest.json',
'images-manifest.json',
'prerender-manifest.json',
'routes-manifest.json',
];
/**
* Returns the path to the Build Output API v2 directory when any
* relevant config file was created by the framework / build script,
* or `undefined` if the framework did not create the v2 output.
*/
export async function getBuildOutputDirectory(
workingDir: string
): Promise<string | undefined> {
const outputDir = path.join(workingDir, BUILD_OUTPUT_DIR);
// check for one of several config files
const finderPromises = CONFIG_FILES.map(configFile => {
return pathExists(path.join(outputDir, configFile));
});
const finders = await Promise.all(finderPromises);
if (finders.some(found => found)) {
return outputDir;
}
return undefined;
}
/**
* Reads the BUILD_OUTPUT_DIR directory and returns and object
* that should be merged with the build outputs.
*/
export async function readBuildOutputDirectory({
workPath,
}: {
workPath: string;
}) {
// Functions are not supported, but are used to support Middleware
const functions: Record<string, EdgeFunction> = {};
// Routes are not supported, but are used to support Middleware
const routes: Array<Route> = [];
const middleware = await getMiddleware(workPath);
if (middleware) {
routes.push(middleware.route);
functions['middleware'] = new EdgeFunction({
deploymentTarget: 'v8-worker',
entrypoint: '_middleware.js',
files: {
'_middleware.js': middleware.file,
},
name: 'middleware',
});
}
const staticFiles = await readStaticFiles({ workPath });
const outputs = {
staticFiles: isObjectEmpty(staticFiles) ? null : staticFiles,
functions: isObjectEmpty(functions) ? null : functions,
routes: routes.length ? routes : null,
};
if (outputs.functions) {
debug(`Detected Serverless Functions in "${BUILD_OUTPUT_DIR}"`);
}
if (outputs.staticFiles) {
debug(`Detected Static Assets in "${BUILD_OUTPUT_DIR}"`);
}
if (outputs.routes) {
debug(`Detected Routes Configuration in "${BUILD_OUTPUT_DIR}"`);
}
return outputs;
}
async function getMiddleware(
workPath: string
): Promise<{ route: Route; file: FileFsRef } | undefined> {
const manifestPath = path.join(
workPath,
BUILD_OUTPUT_DIR,
'functions-manifest.json'
);
try {
const manifest = await readJson(manifestPath);
if (manifest.pages['_middleware.js'].runtime !== 'web') {
return;
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
return;
}
const middlewareRelativePath = path.join(
BUILD_OUTPUT_DIR,
'server/pages/_middleware.js'
);
const middlewareAbsoluatePath = path.join(workPath, middlewareRelativePath);
await appendFile(middlewareAbsoluatePath, BRIDGE_MIDDLEWARE_V2_TO_V3);
const route = {
src: '/(.*)',
middlewarePath: 'middleware',
continue: true,
};
return {
route,
file: new FileFsRef({
fsPath: middlewareRelativePath,
}),
};
}
async function readStaticFiles({
workPath,
}: {
workPath: string;
}): Promise<Files> {
const staticFilePath = path.join(workPath, BUILD_OUTPUT_DIR, 'static');
const staticFiles = await glob('**', {
cwd: staticFilePath,
});
return staticFiles;
}
export async function createBuildOutput(
workPath: string
): Promise<BuildResultV2> {
let output: Files = {};
const routes: Route[] = [];
const extraOutputs = await readBuildOutputDirectory({
workPath,
});
if (extraOutputs.routes) {
routes.push(...extraOutputs.routes);
}
if (extraOutputs.staticFiles) {
output = Object.assign(
{},
extraOutputs.staticFiles,
extraOutputs.functions
);
}
return { routes, output };
}

View File

@@ -1,10 +1,12 @@
import { join } from 'path';
import { promises as fs } from 'fs';
import { BuildResultV2, Meta } from '../../../build-utils/dist';
import { Framework } from '../../../frameworks/dist/types';
const BUILD_OUTPUT_DIR = '.vercel/output';
/**
* Returns the path to the Build Output v3 directory when the
* Returns the path to the Build Output API 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.
*/
@@ -34,3 +36,27 @@ export async function readConfig(
}
return undefined;
}
export function createBuildOutput(
meta: Meta,
buildCommand: string | null,
buildOutputPath: string,
framework?: Framework
): BuildResultV2 {
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,
};
}

View File

@@ -0,0 +1 @@
yarn.lock

View File

@@ -3,5 +3,5 @@ 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>'
'<h1>Build Output API v3</h1>'
);

View File

@@ -0,0 +1 @@
yarn.lock

View File

@@ -0,0 +1,60 @@
const fs = require('fs');
fs.mkdirSync('.output/static', { recursive: true });
fs.mkdirSync('.output/server/pages/api', { recursive: true });
fs.writeFileSync(
'.output/functions-manifest.json',
JSON.stringify(
{
version: 1,
pages: {
'_middleware.js': {
runtime: 'web',
env: [],
files: ['server/pages/_middleware.js'],
name: 'pages/_middleware',
page: '/',
regexp: '^/.*$',
sortingIndex: 1,
},
},
},
null,
2
)
);
fs.writeFileSync('.output/static/index.html', '<h1>Build Output API v2</h1>');
fs.writeFileSync('.output/server/pages/about.html', '<h1>Some Site</h1>');
fs.writeFileSync(
'.output/server/pages/api/user.js',
`export default function handler(request, response) {
response.status(200).json({
body: 'some user info'
});
}`
);
fs.writeFileSync(
'.output/server/pages/_middleware.js',
`
const getResult = (body, options) => ({
promise: Promise.resolve(),
waitUntil: Promise.resolve(),
response: new Response(body, options),
});
_ENTRIES = typeof _ENTRIES === 'undefined' ? {} : _ENTRIES;
_ENTRIES['middleware_pages/_middleware'] = {
default: async function ({ request }) {
return getResult('hi from the edge', {});
},
};
`
);

View File

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

View File

@@ -0,0 +1 @@
yarn.lock

View File

@@ -0,0 +1,32 @@
const fs = require('fs');
fs.mkdirSync('.vercel_build_output/static', { recursive: true });
fs.mkdirSync('.vercel_build_output/config', { recursive: true });
fs.writeFileSync(
'.vercel_build_output/config/functions.json',
JSON.stringify(
{
about: {
memory: 3008,
},
},
null,
2
)
);
fs.writeFileSync(
'.vercel_build_output/static/index.html',
'<h1>Build Output API v1</h1>'
);
fs.mkdirSync('.vercel_build_output/functions/node/about', { recursive: true });
fs.writeFileSync(
'.vercel_build_output/functions/node/about/index.js',
`export default function handler(request, response) {
response.status(200).json({
body: 'some user info'
});
}`
);

View File

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

View File

@@ -0,0 +1,94 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.build
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
# macOS
.DS_Store
# Vim swap files
*.swp
# Vercel
.vercel

View File

@@ -0,0 +1,7 @@
{
"date": "2022-05-18T17:39:45.094Z",
"preset": "nitro-prerender",
"commands": {
"preview": "npx serve -s ./public"
}
}

View File

@@ -0,0 +1,8 @@
{
"node_modules/nuxt/dist/app/entry.mjs": {
"file": "entry-51e0e8ac.mjs",
"src": "node_modules/nuxt/dist/app/entry.mjs",
"isEntry": true,
"css": ["entry.fec640a4.css"]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
{
"node_modules/nuxt/dist/app/entry.mjs": {
"file": "entry-51e0e8ac.mjs",
"src": "node_modules/nuxt/dist/app/entry.mjs",
"isEntry": true,
"css": ["entry.fec640a4.css"]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
README.md
.nuxt
.output
node_modules
*.log

View File

@@ -0,0 +1,10 @@
# 12-build-output-v1-conflict
This fixture was built with the latest (at this time) nuxt 3 release candidate. To produce a potential Build Output API version detection conflict, the following was executed in the fixture directory and committed:
- `yarn nuxi build`: produced the `.output`
- `NOW_BUILDER=1 yarn nuxi build`: produced the `.vercel_build_output`
The `NOW_BUILDER` env var is being detected by the nuxt build to know to produce the Build Output API v1 format.
After creating this fixutre, `"nuxt": "3.0.0-rc.3"` was removed from the `package.json` dependencies so that it would not be installed during every test run. It's not necessary to run a test on this fixture and it was causing CI timeouts.

View File

@@ -0,0 +1,5 @@
<template>
<div>
<NuxtWelcome />
</div>
</template>

View File

@@ -0,0 +1,50 @@
const fs = require('fs');
fs.mkdirSync('.vercel_build_output/static', { recursive: true });
fs.mkdirSync('.vercel_build_output/config', { recursive: true });
fs.writeFileSync(
'.vercel_build_output/config/functions.json',
JSON.stringify(
{
about: {
memory: 3008,
},
},
null,
2
)
);
fs.writeFileSync(
'.vercel_build_output/static/index.html',
'<h1>Build Output API v1</h1>'
);
fs.mkdirSync('.vercel_build_output/functions/node/about', { recursive: true });
fs.writeFileSync(
'.vercel_build_output/functions/node/about/index.js',
`export default function handler(request, response) {
response.status(200).json({
body: 'some user info'
});
}`
);
// .output looks like a Build Output API v2 build, but some frameworks (like Nuxt) use it
// for their own purposes
fs.mkdirSync('.output', { recursive: true });
fs.writeFileSync(
'.output/nitro.json',
JSON.stringify(
{
date: '2022-05-16T16:18:26.958Z',
preset: 'server',
commands: {
preview: 'node ./server/index.mjs',
},
},
null,
2
)
);

View File

@@ -0,0 +1,4 @@
import { defineNuxtConfig } from 'nuxt';
// https://v3.nuxtjs.org/docs/directory-structure/nuxt.config
export default defineNuxtConfig({});

View File

@@ -0,0 +1,10 @@
{
"name": "12-build-output-v1-conflict",
"private": true,
"scripts": {
"dev": "nuxi dev",
"build": "echo 'using already built `nuxi generate` for nuxt3'",
"start": "node .output/server/index.mjs"
},
"dependencies": {}
}

View File

@@ -0,0 +1,4 @@
{
// https://v3.nuxtjs.org/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -1,56 +1,149 @@
import path from 'path';
import { remove } from 'fs-extra';
import { build } from '../src';
describe('build()', () => {
jest.setTimeout(60 * 1000);
jest.setTimeout(60 * 1000);
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',
},
describe('build()', () => {
describe('Build Output API v1', () => {
it('should detect the output format', async () => {
const workPath = path.join(
__dirname,
'build-fixtures',
'11-build-output-v1'
);
try {
const buildResult = await build({
files: {},
entrypoint: 'package.json',
workPath,
config: {},
meta: {
skipDownload: true,
cliVersion: '0.0.0',
},
});
if ('buildOutputVersion' in buildResult) {
throw new Error('Unexpected `buildOutputVersion` in build result');
}
expect(buildResult.output['index.html']).toBeTruthy();
} finally {
remove(path.join(workPath, '.vercel_build_output'));
}
});
it('should detect the v1 output format when .output exists', async () => {
const workPath = path.join(
__dirname,
'build-fixtures',
'12-build-output-v1-conflict'
);
try {
process.env.NOW_BUILDER = '1';
const buildResult = await build({
files: {},
entrypoint: 'package.json',
workPath,
config: {},
meta: {
skipDownload: true,
cliVersion: '0.0.0',
},
});
if ('buildOutputVersion' in buildResult) {
throw new Error('Unexpected `buildOutputVersion` in build result');
}
expect(buildResult.output['index.html']).toBeTruthy();
} finally {
delete process.env.NOW_BUILDER;
}
});
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({
describe('Build Output API v2', () => {
it('should detect the output format', async () => {
const workPath = path.join(
__dirname,
'build-fixtures',
'10-build-output-v2'
);
try {
const buildResult = await build({
files: {},
entrypoint: 'package.json',
workPath,
config: {},
meta: {
skipDownload: true,
cliVersion: '0.0.0',
},
});
if ('buildOutputVersion' in buildResult) {
throw new Error('Unexpected `buildOutputVersion` in build result');
}
expect(buildResult.output['index.html']).toBeTruthy();
expect(buildResult.output['middleware']).toBeTruthy();
} finally {
remove(path.join(workPath, '.output'));
}
});
});
describe('Build Output API v3', () => {
it('should detect the output format', 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',
},
});
} 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.`
);
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 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.`
);
});
});
});