diff --git a/.gitignore b/.gitignore index 1303492c0..6a5659b25 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ coverage/ node_modules/ packages/**/test/**/yarn.lock packages/**/test/**/package-lock.json -public/ \ No newline at end of file +public/ +adapter-outlet/ \ No newline at end of file diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index 6ea8d15e9..fba4ef73f 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -71,6 +71,9 @@ const runProductionBuild = async (compilation) => { const prerenderPlugin = compilation.config.plugins.find(plugin => plugin.type === 'renderer') ? compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(compilation) : {}; + const adapterPlugin = compilation.config.plugins.find(plugin => plugin.type === 'adapter') + ? compilation.config.plugins.find(plugin => plugin.type === 'adapter').provider(compilation) + : null; if (!await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { @@ -114,6 +117,10 @@ const runProductionBuild = async (compilation) => { await bundleCompilation(compilation); await copyAssets(compilation); + if (adapterPlugin) { + await adapterPlugin(); + } + resolve(); } catch (err) { reject(err); diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js index f3afd0ba5..dd8bff451 100644 --- a/packages/cli/src/lifecycles/config.js +++ b/packages/cli/src/lifecycles/config.js @@ -35,7 +35,7 @@ const greenwoodPlugins = (await Promise.all([ }); const optimizations = ['default', 'none', 'static', 'inline']; -const pluginTypes = ['copy', 'context', 'resource', 'rollup', 'server', 'source', 'renderer']; +const pluginTypes = ['copy', 'context', 'resource', 'rollup', 'server', 'source', 'renderer', 'adapter']; const defaultConfig = { workspace: new URL('./src/', cwd), devServer: { diff --git a/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js b/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js new file mode 100644 index 000000000..01a349d6e --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js @@ -0,0 +1,117 @@ +/* + * Use Case + * Run Greenwood with a custom adapter plugin with SSR pages and API routes. + * + * User Result + * Should generate a Greenwood build with expected transformations applied from the plugin. + * + * User Command + * greenwood build + * + * User Config + * async function genericAdapter(compilation, options) { ... } + * + * { + * plugins: [{ + * type: 'adapter', + * name: 'plugin-generic-adapter', + * provider: (compilation, options) => genericAdapter + * }] + * } + * + * Custom Workspace + * src/ + * api/ + * greeting.js + * pages/ + * index.js + * components/ + * card.js + */ +import chai from 'chai'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { JSDOM } from 'jsdom'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Generic Adapter Plugin with SSR Pages + API Routes'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + }); + + // test SSR page + describe('Adapting an SSR Page', function() { + let dom; + + before(async function() { + const req = new Request(new URL('http://localhost:8080/index')); + const { handler } = await import(new URL('./adapter-output/index.js', pathToFileURL(outputPath))); + const response = await handler(req); + const html = await response.text(); + + dom = new JSDOM(html); + }); + + it('should have the expected number of components on the page', function() { + const cards = dom.window.document.querySelectorAll('body > app-card'); + + expect(cards).to.have.lengthOf(1); + }); + + it('should have the expected static heading content rendered inside the component on the page', function() { + const heading = dom.window.document.querySelectorAll('app-card h2'); + + expect(heading).to.have.lengthOf(1); + expect(heading[0].textContent).to.be.equal('Analog'); + }); + + it('should have the expected static img content rendered inside the component on the page', function() { + const img = dom.window.document.querySelectorAll('app-card img'); + + expect(img).to.have.lengthOf(1); + expect(img[0].getAttribute('src')).to.be.equal('https://www.analogstudios.net/images/analog.png'); + }); + }); + + describe('Adapting an API Route', function() { + let data; + + before(async function() { + const handler = (await import(new URL('./adapter-output/greeting.js', pathToFileURL(outputPath)))).handler; + const req = new Request(new URL('http://localhost:8080/api/greeting?name=Greenwood')); + const res = await handler(req); + + data = await res.json(); + }); + + it('should have the expected message from the API when a query is passed', function() { + expect(data.message).to.be.equal('Hello Greenwood!'); + }); + }); + }); + + after(function() { + runner.teardown([ + ...getOutputTeardownFiles(outputPath), + path.join(outputPath, 'adapter-output') + ]); + }); + +}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js b/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js new file mode 100644 index 000000000..42c6dd8c8 --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js @@ -0,0 +1,60 @@ +import fs from 'fs/promises'; +import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js'; + +function generateOutputFormat(id, type) { + const path = type === 'page' + ? `__${id}` + : `api/${id}`; + + return ` + import { handler as ${id} } from '../public/${path}.js'; + + export async function handler (request) { + const { url, headers } = request; + const req = new Request(new URL(url, \`http://\${headers.host}\`), { + headers: new Headers(headers) + }); + + return await ${id}(req); + } + `; +} + +async function genericAdapter(compilation) { + const adapterOutputUrl = new URL('./adapter-output/', compilation.context.projectDirectory); + const ssrPages = compilation.graph.filter(page => page.isSSR); + const apiRoutes = compilation.manifest.apis; + + if (!await checkResourceExists(adapterOutputUrl)) { + await fs.mkdir(adapterOutputUrl); + } + + console.log({ ssrPages, apiRoutes }); + + for (const page of ssrPages) { + const { id } = page; + const outputFormat = generateOutputFormat(id, 'page'); + + await fs.writeFile(new URL(`./${id}.js`, adapterOutputUrl), outputFormat); + } + + // public/api/ + for (const [key] of apiRoutes) { + const id = key.replace('/api/', ''); + const outputFormat = generateOutputFormat(id, 'api'); + + await fs.writeFile(new URL(`./${id}.js`, adapterOutputUrl), outputFormat); + } +} + +const greenwoodPluginAdapterGeneric = (options = {}) => [{ + type: 'adapter', + name: 'plugin-adapter-generic', + provider: (compilation) => { + return async () => { + await genericAdapter(compilation, options); + }; + } +}]; + +export { greenwoodPluginAdapterGeneric }; \ No newline at end of file diff --git a/packages/cli/test/cases/build.plugins.adapter/greenwood.config.js b/packages/cli/test/cases/build.plugins.adapter/greenwood.config.js new file mode 100644 index 000000000..cfbe1a292 --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginAdapterGeneric } from './generic-adapter.js'; + +export default { + plugins: [ + greenwoodPluginAdapterGeneric() + ] +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.plugins.adapter/src/api/greeting.js b/packages/cli/test/cases/build.plugins.adapter/src/api/greeting.js new file mode 100644 index 000000000..c5b8e7ef4 --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/src/api/greeting.js @@ -0,0 +1,12 @@ +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!` }; + const headers = new Headers(); + + headers.append('Content-Type', 'application/json'); + + return new Response(JSON.stringify(body), { + headers + }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/build.plugins.adapter/src/components/card.js b/packages/cli/test/cases/build.plugins.adapter/src/components/card.js new file mode 100644 index 000000000..7b686aca5 --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/src/components/card.js @@ -0,0 +1,22 @@ +export default class Card extends HTMLElement { + + selectArtist() { + alert(`selected artist is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + const thumbnail = this.getAttribute('thumbnail'); + const title = this.getAttribute('title'); + + this.innerHTML = ` +
+

${title}

+ + +
+
+ `; + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/cli/test/cases/build.plugins.adapter/src/pages/index.js b/packages/cli/test/cases/build.plugins.adapter/src/pages/index.js new file mode 100644 index 000000000..db459f91b --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/src/pages/index.js @@ -0,0 +1,24 @@ +import '../components/card.js'; + +export default class ArtistsPage extends HTMLElement { + async connectedCallback() { + const artists = [{ name: 'Analog', imageUrl: 'https://www.analogstudios.net/images/analog.png' }]; + const html = artists.map(artist => { + const { name, imageUrl } = artist; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Artists: ${artists.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/build.plugins.error-type/build.plugins.error-type.spec.js b/packages/cli/test/cases/build.plugins.error-type/build.plugins.error-type.spec.js index 983d8d053..9fedaf24f 100644 --- a/packages/cli/test/cases/build.plugins.error-type/build.plugins.error-type.spec.js +++ b/packages/cli/test/cases/build.plugins.error-type/build.plugins.error-type.spec.js @@ -43,7 +43,7 @@ describe('Build Greenwood With: ', function() { describe('Custom Configuration with a bad value for plugin type', function() { it('should throw an error that plugin.type is not a valid value', async function() { - const pluginTypes = ['copy', 'context', 'resource', 'rollup', 'server', 'source', 'renderer']; + const pluginTypes = ['copy', 'context', 'resource', 'rollup', 'server', 'source', 'renderer', 'adapter']; try { await runner.setup(outputPath); diff --git a/www/pages/plugins/adapter.md b/www/pages/plugins/adapter.md new file mode 100644 index 000000000..76bab683b --- /dev/null +++ b/www/pages/plugins/adapter.md @@ -0,0 +1,108 @@ +--- +label: 'adapter' +menu: side +title: 'Adapter' +index: 1 +--- + +## Adapter + +Adapter plugins are designed with the intent to be able to post-process the Greenwood standard build output. For example, moving output files around into the desired location for a specific hosting provider, like Vercel or AWS. + +> _In particular, plugins built around this API are intended to help Greenwood users ship to serverless and edge runtime environments._ + +## API + +An adapter plugin is simply an `async` function that gets invoked by the Greenwood CLI after all assets, API routes, and SSR pages have been built and optimized. With access to the compilation, you can also process all these files to meet any additional format / output targets. + + +```js +const greenwoodPluginMyPlatformAdapter = (options = {}) => { + return { + type: 'adapter', + name: 'plugin-adapter-my-platform', + provider: (compilation) => { + return async () => { + // run your code here.... + }; + } + }; +}; + +export { + greenwoodPluginMyPlatformAdapter +}; +``` + +## Example + +The most common use case is to "shim" in a hosting platform handler function in front of Greenwood's, which is based on two parameters of `Request` / `Response`. In addition, producing any hosting provided specific metadata is also doable at this stage. + +Here is an example of the "generic adapter" created for Greenwood's own internal test suite. + +```js +import fs from 'fs/promises'; +import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js'; + +function generateOutputFormat(id, type) { + const path = type === 'page' + ? `__${id}` + : `api/${id}`; + + return ` + import { handler as ${id} } from './${path}.js'; + + export async function handler (request) { + const { url, headers } = request; + const req = new Request(new URL(url, \`http://\${headers.host}\`), { + headers: new Headers(headers) + }); + return await ${id}(req); + } + `; +} + +async function genericAdapter(compilation) { + const { outputDir, projectDirectory } = compilation.context; + // custom output directory, like for .vercel or .netlify + const adapterOutputUrl = new URL('./adapter-output/', projectDirectory); + const ssrPages = compilation.graph.filter(page => page.isSSR); + + if (!await checkResourceExists(adapterOutputUrl)) { + await fs.mkdir(adapterOutputUrl); + } + + for (const page of ssrPages) { + const { id } = page; + const outputFormat = generateOutputFormat(id, 'page'); + + // generate a shim for all SSR pages + await fs.writeFile(new URL(`./${id}.js`, adapterOutputUrl), outputFormat); + + // copy all entry points + await fs.cp(new URL(`./_${id}.js`, outputDir), new URL(`./_${id}.js`, adapterOutputUrl)); + await fs.cp(new URL(`./__${id}.js`, outputDir), new URL(`./_${id}.js`, adapterOutputUrl)); + + // generate a manifest + await fs.writeFile(new URL('./metadata.json', adapterOutputUrl), JSON.stringify({ + version: '1.0.0', + runtime: 'nodejs' + // ... + })); + } +} + +const greenwoodPluginAdapterGeneric = (options = {}) => [{ + type: 'adapter', + name: 'plugin-adapter-generic', + provider: (compilation) => { + return async () => { + await genericAdapter(compilation, options); + }; + } +}]; + +export { greenwoodPluginAdapterGeneric }; +``` + +> _**Note**: Check out [Vercel adapter plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-vercel) for a more complete example._ \ No newline at end of file diff --git a/www/pages/plugins/context.md b/www/pages/plugins/context.md index cc67530d7..a8edcd8ea 100644 --- a/www/pages/plugins/context.md +++ b/www/pages/plugins/context.md @@ -2,7 +2,7 @@ label: 'context' menu: side title: 'Context' -index: 1 +index: 2 --- ## Context diff --git a/www/pages/plugins/copy.md b/www/pages/plugins/copy.md index 9070e5227..a2c904776 100644 --- a/www/pages/plugins/copy.md +++ b/www/pages/plugins/copy.md @@ -2,7 +2,7 @@ label: 'copy' menu: side title: 'Copy' -index: 2 +index: 3 --- ## Copy diff --git a/www/pages/plugins/custom-plugins.md b/www/pages/plugins/custom-plugins.md index f6ca7af90..f844fc7c0 100644 --- a/www/pages/plugins/custom-plugins.md +++ b/www/pages/plugins/custom-plugins.md @@ -2,7 +2,7 @@ label: 'custom-plugins' menu: side title: 'Custom Plugins' -index: 8 +index: 9 --- ## Custom Plugins diff --git a/www/pages/plugins/renderer.md b/www/pages/plugins/renderer.md index 732f2adeb..2da5c3784 100644 --- a/www/pages/plugins/renderer.md +++ b/www/pages/plugins/renderer.md @@ -2,7 +2,7 @@ label: 'Renderer' menu: side title: 'Renderer' -index: 4 +index: 5 --- ## Renderer diff --git a/www/pages/plugins/resource.md b/www/pages/plugins/resource.md index a2073dfa9..15ba350d6 100644 --- a/www/pages/plugins/resource.md +++ b/www/pages/plugins/resource.md @@ -2,7 +2,7 @@ label: 'Resource' menu: side title: 'Resource' -index: 3 +index: 4 --- ## Resource diff --git a/www/pages/plugins/rollup.md b/www/pages/plugins/rollup.md index 2e82d1643..cc16e4465 100644 --- a/www/pages/plugins/rollup.md +++ b/www/pages/plugins/rollup.md @@ -2,7 +2,7 @@ label: 'Rollup' menu: side title: 'Rollup' -index: 5 +index: 6 --- ## Rollup diff --git a/www/pages/plugins/server.md b/www/pages/plugins/server.md index 78ba3df93..80730a885 100644 --- a/www/pages/plugins/server.md +++ b/www/pages/plugins/server.md @@ -2,7 +2,7 @@ label: 'Server' menu: side title: 'Server' -index: 6 +index: 7 --- ## Server diff --git a/www/pages/plugins/source.md b/www/pages/plugins/source.md index 08fb16e43..b5aa03ec5 100644 --- a/www/pages/plugins/source.md +++ b/www/pages/plugins/source.md @@ -2,7 +2,7 @@ label: 'source' menu: side title: 'Source' -index: 7 +index: 8 --- ## Source