Skip to content

Commit

Permalink
feat(Template): Introduce template for react extensions (#69)
Browse files Browse the repository at this point in the history
Co-authored-by: Max <[email protected]>
Co-authored-by: Dominik Schubert <[email protected]>
Co-authored-by: Thomas Rausch <[email protected]>
  • Loading branch information
4 people authored Aug 21, 2024
1 parent 3ff9fab commit 3f055ec
Show file tree
Hide file tree
Showing 29 changed files with 769 additions and 0 deletions.
10 changes: 10 additions & 0 deletions templates/react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Extension Template
==================

This is a [cookiecutter](https://github.com/cookiecutter/cookiecutter) template that is used when you invoke.

```console
localstack extensions dev new --template=react
```

It contains a simple python distribution config, and some boilerplate extension code.
11 changes: 11 additions & 0 deletions templates/react/cookiecutter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"project_name": "My LocalStack Extension",
"project_short_description": "All the boilerplate you need to create a LocalStack extension.",
"project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-') }}",
"module_name": "{{ cookiecutter.project_slug.replace('-', '_') }}",
"class_name": "{{ cookiecutter.project_name.replace('-', ' ').replace('_', ' ').title().replace(' ', '') }}",
"full_name": "Jane Doe",
"email": "[email protected]",
"github_username": "janedoe",
"version": "0.1.0"
}
9 changes: 9 additions & 0 deletions templates/react/{{cookiecutter.project_slug}}/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.venv
frontend/node_modules
frontend/.yarn
dist
build
**/*.egg-info
.eggs
__pycache__
*.pyc
66 changes: 66 additions & 0 deletions templates/react/{{cookiecutter.project_slug}}/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
VENV_BIN = python3 -m venv
VENV_DIR ?= .venv
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
VENV_RUN = . $(VENV_ACTIVATE)
FRONTEND_FOLDER = frontend
BACKEND_FOLDER = backend
COREPACK_EXISTS := $(shell command -v corepack)
YARN_EXISTS := $(shell command -v yarn)


INFO_COLOR = \033[0;36m
NO_COLOR = \033[m

venv: $(VENV_ACTIVATE)

$(VENV_ACTIVATE):
test -d .venv || $(VENV_BIN) .venv
$(VENV_RUN); pip install --upgrade pip setuptools plux build wheel
$(VENV_RUN); pip install -e .[dev]
touch $(VENV_DIR)/bin/activate

check-frontend-deps:
@if [ -z "$(YARN_EXISTS)" ]; then \
npm install --global yarn; \
fi
@if [ -z "$(COREPACK_EXISTS)" ]; then \
npm install -g corepack; \
fi

clean: ## Clean the project
rm -rf .venv/
rm -rf build/
rm -rf .eggs/
rm -rf $(BACKEND_FOLDER)/*.egg-info/

install-backend: venv ## Install dependencies of the extension
$(VENV_RUN); python -m plux entrypoints

install-frontend: venv check-frontend-deps ## Install dependencies of the frontend
cd $(FRONTEND_FOLDER) && yarn install

build-frontend: # Build the React app
@if [ ! -d "$(FRONTEND_FOLDER)/node_modules" ]; then \
$(MAKE) install-frontend; \
fi
cd $(FRONTEND_FOLDER); rm -rf build && REACT_APP_DEVELOPMENT_ENVIRONMENT=false NODE_ENV=prod npm run build

start-frontend: ## Start the frontend in dev mode (hot reload)
cd $(FRONTEND_FOLDER); REACT_APP_DEVELOPMENT_ENVIRONMENT=true yarn start

install: venv install-backend install-frontend ## Install dependencies

dist: venv build-frontend ## Create distribution files
$(VENV_RUN); python -m build

publish: clean-dist venv dist ## Build and upload package to pypi
$(VENV_RUN); pip install --upgrade twine; twine upload dist/*

clean-dist: clean ## Remove dist folder
rm -rf dist/

help: ## Show this help
@echo Please specify a build target. The choices are:
@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "$(INFO_COLOR)%-30s$(NO_COLOR) %s\n", $$1, $$2}'

.PHONY: clean clean-dist dist install install-backend install-frontend build-frontend start-frontend publish venv
42 changes: 42 additions & 0 deletions templates/react/{{cookiecutter.project_slug}}/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{{ cookiecutter.project_name }}
===============================

{{ cookiecutter.project_short_description }}

## Install local development version

To install the extension into localstack in developer mode, you will need Python 3.10+, and create a virtual environment in the extensions project.
You will also need to install [yarn](https://yarnpkg.com/getting-started/install) as package manager if you haven't already
In the newly generated project, simply run

```bash
make install
```

Then, to enable the extension for LocalStack, run

```bash
localstack extensions dev enable .
```

You can then start LocalStack with `EXTENSION_DEV_MODE=1` to load all enabled extensions:

```bash
EXTENSION_DEV_MODE=1 localstack start
```

## Developing UI
With this template is generated also a UI made in react that is available at either {{ cookiecutter.project_name }}.localhost.localstack.cloud:4566/ or http://localhost.localstack.cloud:4566/_extension/{{ cookiecutter.project_name }}/.

There are a few make commands available that will help your journey with the UI:
- **build-frontend**: will build the react app into the frontend/build folder which will then be passed into the extension itself allowing the UI to be seen. Remember to always execute this command when you wish to see new changes when using the extension.
- **start-frontend**: will start a live server on port 3000 (by default) that will allow you to have hot reloading when developing locally outside the extension (it will also build the frontend)


## Install from GitHub repository

To distribute your extension, simply upload it to your github account. Your extension can then be installed via:

```bash
localstack extensions install "git+https://github.com/{{cookiecutter.github_username }}/{{ cookiecutter.project_slug }}/#egg={{ cookiecutter.project_slug }}"
```
1 change: 1 addition & 0 deletions templates/react/{{cookiecutter.project_slug}}/backend.pth
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
backend
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
name = "{{ cookiecutter.module_name }}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from localstack.http import route, Request, Response

from .. import static

class WebApp:
@route("/")
def index(self, request: Request, *args, **kwargs):
return Response.for_resource(static, "index.html")

@route("/<path:path>")
def index2(self, request: Request, path: str, **kwargs):
return Response.for_resource(static, path)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging
import typing as t

from localstack.extensions.patterns.webapp import WebAppExtension

from .api.web import WebApp

LOG = logging.getLogger(__name__)


class {{ cookiecutter.class_name }}(WebAppExtension):
name = "{{ cookiecutter.project_slug }}"

def __init__(self):
super().__init__(template_package_path=None)

def collect_routes(self, routes: list[t.Any]):
routes.append(WebApp())

Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/* eslint-disable global-require */

const esbuild = require('esbuild');
const path = require('path');

const SvgrPlugin = require('esbuild-plugin-svgr');
const CopyPlugin = require('esbuild-plugin-copy').default;
const CleanPlugin = require('esbuild-plugin-clean').default;
const { NodeModulesPolyfillPlugin } = require('@esbuild-plugins/node-modules-polyfill');

const packageJson = require('../package.json');
const HtmlPlugin = require('./plugins/html');
const { writeFileSync } = require('fs');

const CURRENT_ENV = process.env.NODE_ENV || 'development.local';
const BUILD_PATH = path.join(__dirname, '..', '..', 'backend', '{{cookiecutter.module_name}}', 'static');

const BUILD_CONFIG = {
entryPoints: [
path.join(__dirname, '..', 'src', 'index.tsx'),
path.join(__dirname, '..', 'src', 'index.html'),
],
assetNames: '[name]-[hash]',
entryNames: '[name]-[hash]',
outdir: BUILD_PATH,
bundle: true,
minify: !CURRENT_ENV.includes('development.local'),
sourcemap: true,
target: 'es2015',
metafile: true,
// splitting: true,
// set in case file loader is added below
plugins: [
CleanPlugin({
patterns: [`${BUILD_PATH}/*`, `!${BUILD_PATH}/index.html`],
sync: true,
verbose: false,
options: {
force: true
}
}),
SvgrPlugin({
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
}),
CopyPlugin({
copyOnStart: true,
// https://github.com/LinbuduLab/nx-plugins/issues/57
assets: [
{
from: ['./public/*'],
to: ['./'],
},
],
}),
NodeModulesPolyfillPlugin(),
HtmlPlugin({
filename: path.join(BUILD_PATH, 'index.html'),
env: true,
}),
],
inject: [path.join(__dirname, 'esbuild.shims.js')],
define: {
// Define replacements for env vars starting with `REACT_APP_`
...Object.entries(process.env).reduce(
(memo, [name, value]) => name.startsWith('REACT_APP_') ?
{ ...memo, [`process.env.${name}`]: JSON.stringify(value) } :
memo,
{},
),
'process.cwd': 'dummyProcessCwd',
global: 'window',
},
external: [
...Object.keys(packageJson.devDependencies || {}),
],
loader: {
'.md': 'text',
'.gif': 'dataurl',
}
};

const build = async (overrides = {}) => {
try {
await esbuild.build({ ...BUILD_CONFIG, ...overrides });
writeFileSync(path.join(BUILD_PATH, '__init__.py'),'')
console.log('done building');
} catch (e) {
console.error(e);
process.exit(1);
}
};

module.exports = { build };
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

export { React };

export function dummyProcessCwd() {
return '';
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { build, serve } = require('./esbuild.config');

(async () => {
if (process.argv.includes('--serve')) {
await serve();
} else if (process.argv.includes('--watch')) {
await build({ watch: true });
} else {
await build();
}
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

/**
* @param {object} config
* @param {string} config.filename - HTML file to process and override
* @param {boolean} config.env - Whether to replace env vars or not (default - `false`)
* @param {string} config.envPrefix - Limit env vars to pick (default - `REACT_APP_`)
*/
const HtmlPlugin = (config) => ({
name: 'html',
setup(build) {
build.onResolve({ filter: /\.html$/ }, args => ({
path: path.resolve(args.resolveDir, args.path),
namespace: 'html',
}));
build.onLoad({ filter: /.html/, namespace: 'html' }, (args) => {
let htmlContent = fs.readFileSync(args.path).toString('utf-8');

// replace env vars
if (config.env) {
const envPrefix = config.envPrefix || 'REACT_APP_';
const envVars = Object.entries(process.env || {}).filter(([name]) => name.startsWith(envPrefix));
htmlContent = envVars.reduce(
(memo, [name, value]) => memo.replace(new RegExp(`%${name}%`, 'igm'), value),
htmlContent,
);
}

return {
contents: htmlContent,
loader: 'file'
};
});

build.onEnd((result) => {
const outFiles = Object.keys((result.metafile || {}).outputs);
const jsFiles = outFiles.filter((p) => p.endsWith('.js'));
const cssFiles = outFiles.filter((p) => p.endsWith('.css'));
const htmlFiles = outFiles.filter((p) => p.endsWith('.html'));

const headerAppends = cssFiles.reduce(
(memo, p) => {
const filename = p.split(path.sep).slice(-1)[0];
return [...memo, `<link href="${filename}" rel="stylesheet">`];
},
[],
);

const bodyAppends = jsFiles.reduce(
(memo, p) => {
const filename = p.split(path.sep).slice(-1)[0];
return [...memo, `<script src="${filename}"></script>`];
},
[],
);

for (const htmlFile of htmlFiles) {
let htmlContent = fs.readFileSync(htmlFile).toString('utf-8');

// replace env vars
if (config.env) {
const envPrefix = config.envPrefix || 'REACT_APP_';
const envVars = Object.entries(process.env).filter(([name]) => name.startsWith(envPrefix));

htmlContent = envVars.reduce(
(memo, [name, value]) => memo.replace(new RegExp(`%${name}%`, 'igm'), value),
htmlContent,
);
}

// inject references to js and css files
htmlContent = htmlContent
.replace('</head>', [...headerAppends, '</head>'].join("\n"))
.replace('</body>', [...bodyAppends, '</body>'].join("\n"));

fs.writeFileSync(config.filename.replace('-[^.]+', ''), htmlContent);
}
});
},
});

module.exports = HtmlPlugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
Empty file.
Loading

0 comments on commit 3f055ec

Please sign in to comment.