Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/issue 1008 netlify adapter plugin #1128

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ coverage/
node_modules/
packages/**/test/**/yarn.lock
packages/**/test/**/package-lock.json
packages/**/test/**/netlify
packages/**/test/**/.netlify
public/
adapter-outlet/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"lint:js": "eslint \"*.{js,md}\" \"./packages/**/**/*.{js,md}\" \"./test/*.js\" \"./www/**/**/*.{js,md}\"",
"lint:ts": "eslint \"./packages/**/**/*.ts\"",
"lint:css": "stylelint \"./www/**/*.js\", \"./www/**/*.css\"",
"lint": "ls-lint && yarn lint:js && yarn lint:ts && yarn lint:css"
"lint": "yarn lint:js && yarn lint:ts && yarn lint:css"
},
"resolutions": {
"lit": "^2.1.1"
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lifecycles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ async function bundleSsrPages(compilation) {
staticHtml = await (await htmlOptimizer.optimize(new URL(`http://localhost:8080${route}`), new Response(staticHtml))).text();

// better way to write out this inline code?
// TODO flesh out response properties
// https://github.com/ProjectEvergreen/greenwood/issues/1048
await fs.writeFile(entryFileUrl, `
import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}';

Expand Down
85 changes: 85 additions & 0 deletions packages/plugin-adapter-netlify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# @greenwood/plugin-adapter-netlify

## Overview
This plugin enables usage of the [Netlify](https://www.netlify.com/) platform for hosting a Greenwood application.

> This package assumes you already have `@greenwood/cli` installed.

## Features

In addition to publishing a project's static assets to the Netlify CDN, this plugin adapts Greenwood [API routes](https://www.greenwoodjs.io/docs/api-routes/) and [SSR pages](https://www.greenwoodjs.io/docs/server-rendering/) into Netlify [Serverless functions](https://docs.netlify.com/functions/overview/) using their [custom build](https://docs.netlify.com/functions/deploy/?fn-language=js#custom-build-2) approach

This plugin will automatically generate a custom [__redirects_](https://docs.netlify.com/routing/redirects/) file to correctly map your SSR page and API route URLs to the corresponding Netlify function endpoint (as a rewrite). You can continue to customize your Netlify project using your _netlify.toml_ file as needed.

> _**Note:** You can see a working example of this plugin [here](https://github.com/ProjectEvergreen/greenwood-demo-adapter-netlify)_.


## Installation
You can use your favorite JavaScript package manager to install this package.

_examples:_
```bash
# npm
npm install @greenwood/plugin-adapter-netlify --save-dev

# yarn
yarn add @greenwood/plugin-adapter-netlify --dev
```


You will then want to create a _netlify.toml_ file at the root of your project (or configure it via the Netlify UI), updating each value as needed per your own project's setup.

```toml
[build]
publish = "public/"
command = "npm run build" # or yarn, pnpm, etc

[build.processing]
skip_processing = true

[build.environment]
NODE_VERSION = "18.x" # or pin to a specific version, like 18.15.0
```

## Usage
Add this plugin to your _greenwood.config.js_.

```javascript
import { greenwoodPluginAdapterNetlify } from '@greenwood/plugin-adapter-netlify';

export default {
...

plugins: [
greenwoodPluginAdapterNetlify()
]
}
```

Optionally, your API routes will have access to Netlify's `context` object as the second parameter to the `handler` function. For example:
```js
export async function handler(request, context = {}) {
console.log({ request, context });
}
```

> _Please see caveats section for more information on this feature. 👇_

## Netlify CLI / Local Development

This plugin comes with the Netlify CLI as a dependency to support some local development testing for previewing a Netlify build locally. Simply add a script like this to your _package.json_
```json
{
"serve:netlify": "greenwood build && netlify dev"
}
```

Then when you run it, you will be able to run and test a production build of your site locally.

> _Please see caveats section for more information on this feature. 👇_

## Caveats
1. [Edge runtime](https://docs.netlify.com/edge-functions/overview/) is not supported yet.
1. Netlify CLI / Local Dev
- [`context` object](https://docs.netlify.com/functions/create/?fn-language=js#code-your-function-2) not supported when running `greenwood develop` command
- [`import.meta.url` is not supported in the Netlify CLI](https://github.com/netlify/cli/issues/4601) and in particular causes [WCC to break](https://github.com/ProjectEvergreen/greenwood-demo-adapter-netlify#-importmetaurl).
37 changes: 37 additions & 0 deletions packages/plugin-adapter-netlify/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@greenwood/plugin-adapter-netlify",
"version": "0.29.0-alpha.1",
"description": "A Greenwood plugin for supporting Netlify serverless and edge runtimes.",
"repository": "https://github.com/ProjectEvergreen/greenwood",
"homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-netlify",
"author": "Owen Buckley <[email protected]>",
"license": "MIT",
"keywords": [
"Greenwood",
"Static Site Generator",
"SSR",
"Full Stack Web Development",
"Netlify",
"Serverless",
"Edge"
],
"main": "src/index.js",
"type": "module",
"files": [
"src/"
],
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@greenwood/cli": "^0.28.0"
},
"dependencies": {
"zip-a-folder": "^2.0.0",
"netlify-cli": "^15.10.0"
},
"devDependencies": {
"@greenwood/cli": "^0.29.0-alpha.1",
"extract-zip": "^2.0.1"
}
}
170 changes: 170 additions & 0 deletions packages/plugin-adapter-netlify/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import fs from 'fs/promises';
import path from 'path';
import { checkResourceExists, normalizePathnameForWindows } from '@greenwood/cli/src/lib/resource-utils.js';
import { zip } from 'zip-a-folder';

// https://docs.netlify.com/functions/create/?fn-language=js
function generateOutputFormat(id) {
return `
import { handler as ${id} } from './__${id}.js';

export async function handler (event, context = {}) {
const { rawUrl, headers, httpMethod } = event;
const request = new Request(rawUrl, {
method: httpMethod,
headers: new Headers(headers)
});
const response = await ${id}(request, context);

return {
statusCode: response.status,
body: await response.text(),
headers: response.headers || new Headers()
};
}
`;
}

async function setupOutputDirectory(id, outputRoot, outputType) {
const outputFormat = generateOutputFormat(id, outputType);
const filename = outputType === 'api'
? `api-${id}`
: `${id}`;

await fs.mkdir(outputRoot, { recursive: true });
await fs.writeFile(new URL(`./${filename}.js`, outputRoot), outputFormat);
await fs.writeFile(new URL('./package.json', outputRoot), JSON.stringify({
type: 'module'
}));
}

// TODO manifest options, like node version?
// https://github.com/netlify/zip-it-and-ship-it#options
async function createOutputZip(id, outputType, outputRootUrl, projectDirectory) {
const filename = outputType === 'api'
? `api-${id}`
: `${id}`;

await zip(
normalizePathnameForWindows(outputRootUrl),
normalizePathnameForWindows(new URL(`./netlify/functions/${filename}.zip`, projectDirectory))
);
}

async function netlifyAdapter(compilation) {
const { outputDir, projectDirectory, scratchDir } = compilation.context;
const adapterOutputUrl = new URL('./netlify/functions/', scratchDir);
const ssrPages = compilation.graph.filter(page => page.isSSR);
const apiRoutes = compilation.manifest.apis;
// https://docs.netlify.com/routing/redirects/
// https://docs.netlify.com/routing/redirects/rewrites-proxies/
// When you assign an HTTP status code of 200 to a redirect rule, it becomes a rewrite.
let redirects = '';

if (!await checkResourceExists(adapterOutputUrl)) {
await fs.mkdir(adapterOutputUrl, { recursive: true });
}

const files = await fs.readdir(outputDir);
const isExecuteRouteModule = files.find(file => file.startsWith('execute-route-module'));

await fs.mkdir(new URL('./netlify/functions/', projectDirectory), { recursive: true });

for (const page of ssrPages) {
const { id } = page;
const outputType = 'page';
const outputRoot = new URL(`./${id}/`, adapterOutputUrl);

await setupOutputDirectory(id, outputRoot, outputType);

await fs.cp(
new URL(`./_${id}.js`, outputDir),
new URL(`./_${id}.js`, outputRoot),
{ recursive: true }
);
await fs.cp(
new URL(`./__${id}.js`, outputDir),
new URL(`./__${id}.js`, outputRoot),
{ recursive: true }
);

// TODO quick hack to make serverless pages are fully self-contained
// for example, execute-route-module.js will only get code split if there are more than one SSR pages
// https://github.com/ProjectEvergreen/greenwood/issues/1118
if (isExecuteRouteModule) {
await fs.cp(
new URL(`./${isExecuteRouteModule}`, outputDir),
new URL(`./${isExecuteRouteModule}`, outputRoot)
);
}

// TODO how to track SSR resources that get dumped out in the public directory?
// https://github.com/ProjectEvergreen/greenwood/issues/1118
const ssrPageAssets = (await fs.readdir(outputDir))
.filter(file => !path.basename(file).startsWith('_')
&& !path.basename(file).startsWith('execute')
&& path.basename(file).endsWith('.js')
);

for (const asset of ssrPageAssets) {
await fs.cp(
new URL(`./${asset}`, outputDir),
new URL(`./${asset}`, outputRoot),
{ recursive: true }
);
}

await createOutputZip(id, outputType, new URL(`./${id}/`, adapterOutputUrl), projectDirectory);

redirects += `/${id}/ /.netlify/functions/${id} 200
`;
}

if (apiRoutes.size > 0) {
redirects += '/api/* /.netlify/functions/api-:splat 200';
}

for (const [key] of apiRoutes) {
const outputType = 'api';
const id = key.replace('/api/', '');
const outputRoot = new URL(`./api/${id}/`, adapterOutputUrl);

await setupOutputDirectory(id, outputRoot, outputType);

// TODO ideally all functions would be self contained
// https://github.com/ProjectEvergreen/greenwood/issues/1118
await fs.cp(
new URL(`./api/${id}.js`, outputDir),
new URL(`./__${id}.js`, outputRoot),
{ recursive: true }
);

if (await checkResourceExists(new URL('./api/assets/', outputDir))) {
await fs.cp(
new URL('./api/assets/', outputDir),
new URL('./assets/', outputRoot),
{ recursive: true }
);
}

// NOTE: All functions must live at the top level
// https://github.com/netlify/netlify-lambda/issues/90#issuecomment-486047201
await createOutputZip(id, outputType, outputRoot, projectDirectory);
}

if (redirects !== '') {
await fs.writeFile(new URL('./_redirects', outputDir), redirects);
}
}

const greenwoodPluginAdapterNetlify = (options = {}) => [{
type: 'adapter',
name: 'plugin-adapter-netlify',
provider: (compilation) => {
return async () => {
await netlifyAdapter(compilation, options);
};
}
}];

export { greenwoodPluginAdapterNetlify };
Loading
Loading