mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-11 04:22:13 +00:00
This PR:
- updates `packages/frameworks` to have most supported frameworks specify which dependency version should reflect the overall framework version
- updates `packages/fs-detectors` to allow framework detection that returns the full `Framework` record instead of just the slug
- updates `packages/next` to return the detected Next.js version in the build result
- updates `packages/cli` to leverage these changes so that `vc build` can add `framework: { version: string; }` to `config.json` output
The result is that Build Output API and supported frameworks will return their framework version in the build result of `vc build` when possible, which is used by the build container when creating the deployment. The dashboard later retrieves this value to display in richer deployment outputs.
Supports:
- https://github.com/vercel/api/pull/15601
- https://github.com/vercel/front/pull/18319
---
With the related build container updates, we get to see Next.js version in the build output. You'll see this with BOA+Prebuilt or a normal deploy:
<img width="1228" alt="Screen Shot 2022-12-09 at 2 48 12 PM" src="https://user-images.githubusercontent.com/41545/206793639-f9cd3bdf-b822-45dd-9564-95b94994271d.png">
---
### The Path to this PR
I went through all the supported frameworks and figured out how to best determine their versions. For most of them, we can check a known dependency's installed version number.
We can get most of the way only checking npm. For a handful, we'd have to support Go/Ruby/Rust/Whatever dependencies.
I started with a more complex method signature to allow for later expansion without changing the signature. It looked like this, in practice:
```
async getVersion(dependencies: DependencyMap) => depedencies['next']
```
However, after checking all currently supported frameworks, I don't think this will end up being necessary. It also has the constraint that all dependencies have to be gathered and presented to the function even though it only needs to check for one or two. That's not a huge deal if we have them already where we need them, but we don't. We could use a variant here where this function does its own lookups, but this seemed unnecessary and would beg for duplication and small variances that could cause bugs.
Further, if we only look at `package.json`, we're going to either see a specific version of a version range. To be precise, we have to look at the installed version of the package. That means checking one of the various types of lockfiles that can exist or poking into node_modules.
If we poke into node_modules to detect the installed version, we introduce another point where Yarn 3 (default mode) will not be supported. If we read lockfiles, we have to potentially parse `npm`, `pnpm`, and `yarn` lockfiles.
If we use `npm ls <package-name>`, that also fails in Yarn 3 (default mode). We could accept that and go forward anyway, which would look like:
```
const args = `ls ${packageName} --depth=0 --json`.split(' ');
const { stdout } = await execa('npm', args, { cwd });
const regex = new RegExp(String.raw`${packageName}@([\.\d]+)`);
const matches = stdout.match(regex);
if (matches) {
return matches[1];
}
```
But it turns out there's a `--json` option! That's what I ended up using, for now.
We could explore the lockfile route more, but after some initial digging, it' non-trivial. There are 3 main lockfiles we'd want to check for (npm, pnpm, and yarn) and there are different lockfile versions that put necessary data in different places. I looked for existing tools that parse this, but I didn't find any. We could certainly go down this path, but the effort doesn't seem worth it when `npm ls` gets us really close.
---
### Follow-up Versioning
Now that we know how to determine version per framework, we can vary configuration by version. In a future PR, we could allow a given value to vary by version number:
```
name: (version) => {
if (semver.gt(version, '9.8.7')) {
return 'some-framework-2''
}
return 'some-framework';
}
```
However, it may still be easier to differentiate significant versions by adding multiple entries in the list.
309 lines
9.1 KiB
TypeScript
Vendored
309 lines
9.1 KiB
TypeScript
Vendored
import frameworkList from '@vercel/frameworks';
|
|
import workspaceManagers from '../src/workspaces/workspace-managers';
|
|
import { detectFramework } from '../src';
|
|
import VirtualFilesystem from './virtual-file-system';
|
|
|
|
describe('DetectorFilesystem', () => {
|
|
it('should return the directory contents relative to the cwd', async () => {
|
|
const files = {
|
|
'package.json': '{}',
|
|
'packages/app1/package.json': '{}',
|
|
'packages/app2/package.json': '{}',
|
|
};
|
|
|
|
const fs = new VirtualFilesystem(files);
|
|
const hasPathSpy = jest.spyOn(fs, '_hasPath');
|
|
|
|
expect(await fs.readdir('/', { potentialFiles: ['config.rb'] })).toEqual([
|
|
{ name: 'package.json', path: 'package.json', type: 'file' },
|
|
{ name: 'packages', path: 'packages', type: 'dir' },
|
|
]);
|
|
expect(await fs.hasPath('package.json')).toBe(true);
|
|
expect(hasPathSpy).not.toHaveBeenCalled();
|
|
expect(await fs.hasPath('config.rb')).toBe(false);
|
|
expect(hasPathSpy).not.toHaveBeenCalled();
|
|
expect(await fs.hasPath('tsconfig.json')).toBe(false);
|
|
expect(hasPathSpy).toHaveBeenCalled();
|
|
|
|
expect(await fs.readdir('packages')).toEqual([
|
|
{ name: 'app1', path: 'packages/app1', type: 'dir' },
|
|
{ name: 'app2', path: 'packages/app2', type: 'dir' },
|
|
]);
|
|
|
|
expect(await fs.readdir('./packages')).toEqual([
|
|
{ name: 'app1', path: 'packages/app1', type: 'dir' },
|
|
{ name: 'app2', path: 'packages/app2', type: 'dir' },
|
|
]);
|
|
|
|
expect(
|
|
await fs.readdir('packages/app1', { potentialFiles: ['package.json'] })
|
|
).toEqual([
|
|
{
|
|
name: 'package.json',
|
|
path: 'packages/app1/package.json',
|
|
type: 'file',
|
|
},
|
|
]);
|
|
|
|
hasPathSpy.mock.calls.length = 0;
|
|
expect(await fs.hasPath('packages/app1/package.json')).toBe(true);
|
|
expect(hasPathSpy).not.toHaveBeenCalled();
|
|
|
|
expect(
|
|
await fs.readdir('packages/app1', { potentialFiles: ['vercel.json'] })
|
|
).toEqual([
|
|
{
|
|
name: 'package.json',
|
|
path: 'packages/app1/package.json',
|
|
type: 'file',
|
|
},
|
|
]);
|
|
|
|
hasPathSpy.mock.calls.length = 0;
|
|
expect(await fs.hasPath('packages/app1/vercel.json')).toBe(false);
|
|
expect(hasPathSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should be able to write files', async () => {
|
|
const files = {};
|
|
const fs = new VirtualFilesystem(files);
|
|
const hasPathSpy = jest.spyOn(fs, '_hasPath');
|
|
const isFileSpy = jest.spyOn(fs, '_isFile');
|
|
const readFileSpy = jest.spyOn(fs, '_readFile');
|
|
|
|
await fs.writeFile('file.txt', 'Hello World');
|
|
|
|
expect(await fs.readFile('file.txt')).toEqual(Buffer.from('Hello World'));
|
|
expect(await fs.hasPath('file.txt')).toBe(true);
|
|
expect(await fs.isFile('file.txt')).toBe(true);
|
|
// We expect that the fs returned values from it's caches instead of calling the underlying functions
|
|
expect(hasPathSpy).not.toHaveBeenCalled();
|
|
expect(isFileSpy).not.toHaveBeenCalled();
|
|
expect(readFileSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should be able to change directories', async () => {
|
|
const nextPackageJson = JSON.stringify({
|
|
dependencies: {
|
|
next: '9.0.0',
|
|
},
|
|
});
|
|
const gatsbyPackageJson = JSON.stringify({
|
|
dependencies: {
|
|
gatsby: '1.0.0',
|
|
},
|
|
});
|
|
|
|
const files = {
|
|
'package.json': '{}',
|
|
'packages/app1/package.json': nextPackageJson,
|
|
'packages/app2/package.json': gatsbyPackageJson,
|
|
};
|
|
|
|
const fs = new VirtualFilesystem(files);
|
|
const packagesFs = fs.chdir('packages');
|
|
|
|
expect(await packagesFs.readdir('/')).toEqual([
|
|
{ name: 'app1', path: 'app1', type: 'dir' },
|
|
{ name: 'app2', path: 'app2', type: 'dir' },
|
|
]);
|
|
|
|
expect(await packagesFs.hasPath('app1')).toBe(true);
|
|
expect(await packagesFs.hasPath('app3')).toBe(false);
|
|
expect(await packagesFs.isFile('app1')).toBe(false);
|
|
expect(await packagesFs.isFile('app2')).toBe(false);
|
|
expect(await packagesFs.isFile('app1/package.json')).toBe(true);
|
|
expect(await packagesFs.isFile('app2/package.json')).toBe(true);
|
|
expect(
|
|
await (await packagesFs.readFile('app1/package.json')).toString()
|
|
).toEqual(nextPackageJson);
|
|
expect(
|
|
await (await packagesFs.readFile('app2/package.json')).toString()
|
|
).toEqual(gatsbyPackageJson);
|
|
|
|
expect(await detectFramework({ fs: packagesFs, frameworkList })).toBe(null);
|
|
|
|
const nextAppFs = packagesFs.chdir('app1');
|
|
|
|
expect(await nextAppFs.readdir('/')).toEqual([
|
|
{ name: 'package.json', path: 'package.json', type: 'file' },
|
|
]);
|
|
|
|
expect(await (await nextAppFs.readFile('package.json')).toString()).toEqual(
|
|
nextPackageJson
|
|
);
|
|
|
|
expect(await detectFramework({ fs: nextAppFs, frameworkList })).toBe(
|
|
'nextjs'
|
|
);
|
|
|
|
const gatsbyAppFs = packagesFs.chdir('./app2');
|
|
|
|
expect(await gatsbyAppFs.readdir('/')).toEqual([
|
|
{ name: 'package.json', path: 'package.json', type: 'file' },
|
|
]);
|
|
|
|
expect(
|
|
await (await gatsbyAppFs.readFile('package.json')).toString()
|
|
).toEqual(gatsbyPackageJson);
|
|
|
|
expect(await detectFramework({ fs: gatsbyAppFs, frameworkList })).toBe(
|
|
'gatsby'
|
|
);
|
|
});
|
|
|
|
describe('#detectFramework', () => {
|
|
it('Do not detect anything', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'README.md': '# hi',
|
|
'api/cheese.js': 'export default (req, res) => res.end("cheese");',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe(null);
|
|
});
|
|
|
|
it('Detect nx', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'workspace.json': JSON.stringify({
|
|
projects: { 'app-one': 'apps/app-one' },
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
await detectFramework({ fs, frameworkList: workspaceManagers })
|
|
).toBe('nx');
|
|
});
|
|
|
|
it('Do not detect anything', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'workspace.json': JSON.stringify({ projects: {} }),
|
|
});
|
|
|
|
expect(
|
|
await detectFramework({ fs, frameworkList: workspaceManagers })
|
|
).toBe(null);
|
|
});
|
|
|
|
it('Detect Next.js', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'package.json': JSON.stringify({
|
|
dependencies: {
|
|
next: '9.0.0',
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('nextjs');
|
|
});
|
|
|
|
it('Detect frameworks based on ascending order in framework list', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'package.json': JSON.stringify({
|
|
dependencies: {
|
|
next: '9.0.0',
|
|
gatsby: '4.18.0',
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('nextjs');
|
|
});
|
|
|
|
it('Detect Nuxt.js', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'package.json': JSON.stringify({
|
|
dependencies: {
|
|
nuxt: '1.0.0',
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('nuxtjs');
|
|
});
|
|
|
|
it('Detect Gatsby', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'package.json': JSON.stringify({
|
|
dependencies: {
|
|
gatsby: '1.0.0',
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('gatsby');
|
|
});
|
|
|
|
it('Detect Hugo #1', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'config.yaml': 'baseURL: http://example.org/',
|
|
'content/post.md': '# hello world',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('hugo');
|
|
});
|
|
|
|
it('Detect Hugo #2', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'config.json': '{ "baseURL": "http://example.org/" }',
|
|
'content/post.md': '# hello world',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('hugo');
|
|
});
|
|
|
|
it('Detect Hugo #3', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'config.toml': 'baseURL = "http://example.org/"',
|
|
'content/post.md': '# hello world',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('hugo');
|
|
});
|
|
|
|
it('Detect Jekyll', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'_config.yml': 'config',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('jekyll');
|
|
});
|
|
|
|
it('Detect Middleman', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'config.rb': 'config',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('middleman');
|
|
});
|
|
|
|
it('Detect Scully', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'package.json': JSON.stringify({
|
|
dependencies: {
|
|
'@angular/cli': 'latest',
|
|
'@scullyio/init': 'latest',
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('scully');
|
|
});
|
|
|
|
it('Detect Zola', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'config.toml': 'base_url = "/"',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('zola');
|
|
});
|
|
|
|
it('Detect Blitz.js (Legacy)', async () => {
|
|
const fs = new VirtualFilesystem({
|
|
'blitz.config.js': '// some config',
|
|
});
|
|
|
|
expect(await detectFramework({ fs, frameworkList })).toBe('blitzjs');
|
|
});
|
|
});
|
|
});
|