[cli] Serialize duplicate EdgeFunction references as symlinks in vc build (#11027)

Enables the symlink optimization that currently exists for `Lambda`
instances, but now for `EdgeFunction` instances as well. This will be
particularly beneficial for Remix applications which use edge functions
for many routes, since they will now all be represented by the same
underling function in production.

---------

Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
This commit is contained in:
Nathan Rajlich
2024-01-10 13:17:59 -08:00
committed by GitHub
parent 98040ec24e
commit ab826eb83d
9 changed files with 159 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
'vercel': minor
---
Serialize duplicate `EdgeFunction` references as symlinks in `vc build`

View File

@@ -129,13 +129,19 @@ async function writeBuildResultV2(
);
}
const lambdas = new Map<Lambda, string>();
const existingFunctions = new Map<Lambda | EdgeFunction, string>();
const overrides: Record<string, PathOverride> = {};
for (const [path, output] of Object.entries(buildResult.output)) {
const normalizedPath = stripDuplicateSlashes(path);
if (isLambda(output)) {
await writeLambda(outputDir, output, normalizedPath, undefined, lambdas);
await writeLambda(
outputDir,
output,
normalizedPath,
undefined,
existingFunctions
);
} else if (isPrerender(output)) {
if (!output.lambda) {
throw new Error(
@@ -148,7 +154,7 @@ async function writeBuildResultV2(
output.lambda,
normalizedPath,
undefined,
lambdas
existingFunctions
);
// Write the fallback file alongside the Lambda directory
@@ -203,7 +209,12 @@ async function writeBuildResultV2(
vercelConfig?.cleanUrls
);
} else if (isEdgeFunction(output)) {
await writeEdgeFunction(outputDir, output, normalizedPath);
await writeEdgeFunction(
outputDir,
output,
normalizedPath,
existingFunctions
);
} else {
throw new Error(
`Unsupported output type: "${
@@ -315,19 +326,65 @@ async function writeStaticFile(
await downloadFile(file, dest);
}
/**
* If the `fn` Lambda or Edge function has already been written to
* the filesystem at a different location, then create a symlink
* to the previous location instead of copying the files again.
*
* @param outputPath The path of the `.vercel/output` directory
* @param dest The path of destination function's `.func` directory
* @param fn The Lambda or EdgeFunction instance to create the symlink for
* @param existingFunctions Map of `Lambda`/`EdgeFunction` instances that have previously been written
*/
async function writeFunctionSymlink(
outputDir: string,
dest: string,
fn: Lambda | EdgeFunction,
existingFunctions: Map<Lambda | EdgeFunction, string>
) {
const existingPath = existingFunctions.get(fn);
// Function has not been written to the filesystem, so bail
if (!existingPath) return false;
const destDir = dirname(dest);
const targetDest = join(outputDir, 'functions', `${existingPath}.func`);
const target = relative(destDir, targetDest);
await fs.mkdirp(destDir);
await fs.symlink(target, dest);
return true;
}
/**
* Serializes the `EdgeFunction` instance to the file system.
*
* @param outputPath The path of the `.vercel/output` directory
* @param edgeFunction The `EdgeFunction` instance
* @param path The URL path where the `EdgeFunction` can be accessed from
* @param existingFunctions (optional) Map of `Lambda`/`EdgeFunction` instances that have previously been written
*/
async function writeEdgeFunction(
outputDir: string,
edgeFunction: EdgeFunction,
path: string
path: string,
existingFunctions?: Map<Lambda | EdgeFunction, string>
) {
const dest = join(outputDir, 'functions', `${path}.func`);
if (existingFunctions) {
if (
await writeFunctionSymlink(
outputDir,
dest,
edgeFunction,
existingFunctions
)
) {
return;
}
existingFunctions.set(edgeFunction, path);
}
await fs.mkdirp(dest);
const ops: Promise<any>[] = [];
ops.push(download(edgeFunction.files, dest));
@@ -351,36 +408,29 @@ async function writeEdgeFunction(
/**
* Writes the file references from the `Lambda` instance to the file system.
*
* @param outputPath The path of the `.vercel/output` directory
* @param lambda The `Lambda` instance
* @param path The URL path where the `Lambda` can be accessed from
* @param lambdas (optional) Map of `Lambda` instances that have previously been written
* @param functionConfiguration (optional) Extra configuration to apply to the function's `.vc-config.json` file
* @param existingFunctions (optional) Map of `Lambda`/`EdgeFunction` instances that have previously been written
*/
async function writeLambda(
outputDir: string,
lambda: Lambda,
path: string,
functionConfiguration?: FunctionConfiguration,
lambdas?: Map<Lambda, string>
existingFunctions?: Map<Lambda | EdgeFunction, string>
) {
const dest = join(outputDir, 'functions', `${path}.func`);
// If the `lambda` has already been written to the filesystem at a different
// location then create a symlink to the previous location instead of copying
// the files again.
const existingLambdaPath = lambdas?.get(lambda);
if (existingLambdaPath) {
const destDir = dirname(dest);
const targetDest = join(
outputDir,
'functions',
`${existingLambdaPath}.func`
);
const target = relative(destDir, targetDest);
await fs.mkdirp(destDir);
await fs.symlink(target, dest);
return;
if (existingFunctions) {
if (
await writeFunctionSymlink(outputDir, dest, lambda, existingFunctions)
) {
return;
}
existingFunctions.set(lambda, path);
}
lambdas?.set(lambda, path);
await fs.mkdirp(dest);
const ops: Promise<any>[] = [];

View File

@@ -0,0 +1 @@
!node_modules

View File

@@ -0,0 +1,21 @@
const { Lambda, EdgeFunction } = require('@vercel/build-utils');
exports.build = async () => {
const lambda = new Lambda({
files: {},
runtime: 'provided',
handler: 'example.js'
});
const edge = new EdgeFunction({
files: {},
deploymentTarget: 'v8-worker',
entrypoint: 'example.js'
});
const output = {
lambda,
lambda2: lambda,
edge,
edge2: edge
};
return { output };
};

View File

@@ -0,0 +1,6 @@
{
"name": "functions-symlink",
"private": true,
"version": "0.0.0",
"main": "main.js"
}

View File

@@ -0,0 +1,7 @@
{
"orgId": ".",
"projectId": ".",
"settings": {
"framework": null
}
}

View File

@@ -0,0 +1,4 @@
{
"name": "functions-symlink-test",
"private": true
}

View File

@@ -0,0 +1,3 @@
{
"builds": [{ "src": "package.json", "use": "functions-symlink@0.0.0" }]
}

View File

@@ -1248,3 +1248,42 @@ describe('build', () => {
).toEqual('marketing');
});
});
it('should create symlinks for duplicate references to Lambda / EdgeFunction instances', async () => {
if (process.platform === 'win32') {
console.log('Skipping test on Windows');
return;
}
const cwd = fixture('functions-symlink');
const output = join(cwd, '.vercel/output');
client.cwd = cwd;
const exitCode = await build(client);
expect(exitCode).toEqual(0);
// "functions" directory has output Functions
const functions = await fs.readdir(join(output, 'functions'));
expect(functions.sort()).toEqual([
'edge.func',
'edge2.func',
'lambda.func',
'lambda2.func',
]);
expect(
fs.lstatSync(join(output, 'functions/lambda.func')).isDirectory()
).toEqual(true);
expect(
fs.lstatSync(join(output, 'functions/edge.func')).isDirectory()
).toEqual(true);
expect(
fs.lstatSync(join(output, 'functions/lambda2.func')).isSymbolicLink()
).toEqual(true);
expect(
fs.lstatSync(join(output, 'functions/edge2.func')).isSymbolicLink()
).toEqual(true);
expect(fs.readlinkSync(join(output, 'functions/lambda2.func'))).toEqual(
'lambda.func'
);
expect(fs.readlinkSync(join(output, 'functions/edge2.func'))).toEqual(
'edge.func'
);
});