Skip to content

Commit

Permalink
feature/issue 1008 netlify adapter plugin (#1128)
Browse files Browse the repository at this point in the history
* create initial working version of a netlify adapter plugin

* add test case for greeting API route adapter

* add test case for SSR page output

* README clarifications

* add fragments API and HTTP method support from Netlify event

* file output setup and zipping refactoring

* auto-generate _redirects file based on pages and APIs

* redirects should be rewrites instead

* document recommended Netlify project configuration setup

* document caveats

* link to demonstration repo

* document adapter netlify on custom plugins page

* add netlify-cli as a dependency

* Netlify CLI integration and clarifications

* disable linting due to typescript version conflicts

* clarify redirects and rewrites in README

* update pathname handling for windows interop

* add test coverage for SSR pages content type

* README refresh
  • Loading branch information
thescientist13 committed Aug 12, 2023
1 parent fdf2b14 commit 37c6a17
Show file tree
Hide file tree
Showing 18 changed files with 5,966 additions and 77 deletions.
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

0 comments on commit 37c6a17

Please sign in to comment.