diff --git a/packages/plugin-adapter-vercel/README.md b/packages/plugin-adapter-vercel/README.md index 4850eeb91..f60c275a9 100644 --- a/packages/plugin-adapter-vercel/README.md +++ b/packages/plugin-adapter-vercel/README.md @@ -61,7 +61,26 @@ export default { } ``` +## Options + +### Runtime + +Vercel supports [multiple semver major NodeJS versions](https://vercel.com/docs/functions/runtimes/node-js/node-js-versions#default-and-available-versions) for the serverless runtime as part of the [build output API](https://vercel.com/docs/build-output-api/v3/primitives#serverless-functions). With the **runtime** option, you can configure your functions for any supported NodeJS version. Current default version is `nodejs20.x`. + +```javascript +import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + +export default { + plugins: [ + greenwoodPluginAdapterVercel({ + runtime: 'nodejs22.x' + }) + ] +} +``` + ## Caveats + 1. [Edge runtime](https://vercel.com/docs/concepts/functions/edge-functions) is not supported ([yet](https://github.com/ProjectEvergreen/greenwood/issues/1141)). 1. The Vercel CLI (`vercel dev`) is not compatible with Build Output v3. ```sh diff --git a/packages/plugin-adapter-vercel/src/index.js b/packages/plugin-adapter-vercel/src/index.js index a7b8a098e..c345a6a4f 100644 --- a/packages/plugin-adapter-vercel/src/index.js +++ b/packages/plugin-adapter-vercel/src/index.js @@ -2,6 +2,8 @@ import fs from 'fs/promises'; import path from 'path'; import { checkResourceExists } from '@greenwood/cli/src/lib/resource-utils.js'; +const DEFAULT_RUNTIME = 'nodejs20.x'; + // https://vercel.com/docs/functions/serverless-functions/runtimes/node-js#node.js-helpers function generateOutputFormat(id, type) { const handlerAlias = '$handler'; @@ -51,7 +53,7 @@ function generateOutputFormat(id, type) { `; } -async function setupFunctionBuildFolder(id, outputType, outputRoot) { +async function setupFunctionBuildFolder(id, outputType, outputRoot, runtime) { const outputFormat = generateOutputFormat(id, outputType); await fs.mkdir(outputRoot, { recursive: true }); @@ -60,14 +62,15 @@ async function setupFunctionBuildFolder(id, outputType, outputRoot) { type: 'module' })); await fs.writeFile(new URL('./.vc-config.json', outputRoot), JSON.stringify({ - runtime: 'nodejs18.x', + runtime, handler: 'index.js', launcherType: 'Nodejs', shouldAddHelpers: true })); } -async function vercelAdapter(compilation) { +async function vercelAdapter(compilation, options) { + const { runtime = DEFAULT_RUNTIME } = options; const { outputDir, projectDirectory } = compilation.context; const { basePath } = compilation.config; const adapterOutputUrl = new URL('./.vercel/output/functions/', projectDirectory); @@ -89,7 +92,7 @@ async function vercelAdapter(compilation) { const chunks = (await fs.readdir(outputDir)) .filter(file => file.startsWith(`${id}.route.chunk`) && file.endsWith('.js')); - await setupFunctionBuildFolder(id, outputType, outputRoot); + await setupFunctionBuildFolder(id, outputType, outputRoot, runtime); // handle user's actual route entry file await fs.cp( diff --git a/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/build.default.options-runtime.spec.js b/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/build.default.options-runtime.spec.js new file mode 100644 index 000000000..055a4200d --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/build.default.options-runtime.spec.js @@ -0,0 +1,155 @@ +/* + * Use Case + * Run Greenwood with the Vercel adapter plugin and setting the runtime option. + * + * User Result + * Should generate a static Greenwood build with serverless and edge functions output. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + * + * { + * plugins: [{ + * greenwoodPluginAdapterVercel({ + * runtime: 'nodejs22.x + * }) + * }] + * } + * + * User Workspace + * package.json + * src/ + * pages/ + * index.js + */ +import chai from 'chai'; +import fs from 'fs/promises'; +import glob from 'glob-promise'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { checkResourceExists } from '@greenwood/cli/src/lib/resource-utils.js'; +import { getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { normalizePathnameForWindows } from '@greenwood/cli/src/lib/resource-utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Vercel Adapter plugin output with runtime option set'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const vercelOutputFolder = new URL('./.vercel/output/', import.meta.url); + const vercelFunctionsOutputUrl = new URL('./functions/', vercelOutputFolder); + const hostname = 'http://www.example.com'; + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(function() { + runner.setup(outputPath); + runner.runCommand(cliPath, 'build'); + }); + + describe('Default Output', function() { + let configFile; + let functionFolders; + + before(async function() { + configFile = await fs.readFile(new URL('./config.json', vercelOutputFolder), 'utf-8'); + functionFolders = await glob.promise(path.join(normalizePathnameForWindows(vercelFunctionsOutputUrl), '**/*.func')); + }); + + it('should output the expected number of serverless function output folders', function() { + expect(functionFolders.length).to.be.equal(1); + }); + + it('should output the expected configuration file for the build output', function() { + expect(configFile).to.be.equal('{"version":3}'); + }); + + it('should output the expected package.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./package.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"type":"module"}'); + }); + }); + + it('should output the expected .vc-config.json for each serverless function with runtime option honored', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./vc-config.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"runtime":"nodejs22.x","handler":"index.js","launcherType":"Nodejs","shouldAddHelpers":true}'); + }); + }); + }); + + describe('Static directory output', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const publicFiles = await glob.promise(path.join(outputPath, 'public/**/**')); + + for (const file of publicFiles) { + const buildOutputDestination = file.replace(path.join(outputPath, 'public'), path.join(vercelOutputFolder.pathname, 'static')); + const itExists = await checkResourceExists(new URL(`file://${buildOutputDestination}`)); + + expect(itExists).to.be.equal(true); + } + }); + }); + + describe('Index SSR Page adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./index.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: `${hostname}/`, + headers: { + host: hostname + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(status).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('text/html'); + + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal('Just here causing trouble! :D'); + }); + }); + }); + + after(function() { + runner.teardown([ + path.join(outputPath, '.vercel'), + ...getOutputTeardownFiles(outputPath) + ]); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/greenwood.config.js b/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/greenwood.config.js new file mode 100644 index 000000000..501982fb3 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/greenwood.config.js @@ -0,0 +1,9 @@ +import { greenwoodPluginAdapterVercel } from '../../../src/index.js'; + +export default { + plugins: [ + greenwoodPluginAdapterVercel({ + runtime: 'nodejs22.x' + }) + ] +}; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/src/pages/index.js b/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/src/pages/index.js new file mode 100644 index 000000000..6d9387b6e --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default.options-runtime/src/pages/index.js @@ -0,0 +1,5 @@ +export default class IndexPage extends HTMLElement { + connectedCallback() { + this.innerHTML = '

Just here causing trouble! :D

'; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js index ad0779c5d..f69b27325 100644 --- a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js +++ b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js @@ -106,7 +106,7 @@ describe('Build Greenwood With: ', function() { functionFolders.forEach(async (folder) => { const packageJson = await fs.readFile(new URL('./vc-config.json', `file://${folder}/`), 'utf-8'); - expect(packageJson).to.be.equal('{"runtime":"nodejs18.x","handler":"index.js","launcherType":"Nodejs","shouldAddHelpers":true}'); + expect(packageJson).to.be.equal('{"runtime":"nodejs20.x","handler":"index.js","launcherType":"Nodejs","shouldAddHelpers":true}'); }); }); });