CRA Browser Extension
☕️☕️☕️ 14 min read
Ever wanted to use CRA as foundation for building an awesome browser extension but weren’t sure how to get everything working?
I’m going to show you how I would modify create-react-app application so that it can be used as perfect baseline for your project.
1. Create a new app with Create React App
Let’s start by creating a new React app:
npx create-react-app cra-extension
2. Setup the manifest
By default Create React App creates a Web App manifest in the /public dir. We don’t need it: a browser extension requires a WebExtension API manifest, which follows a completely different standard.
Replace the content of public/manifest.json:
{
"name": "CRA Extension",
"version": "1.0.0",
"manifest_version": 2,
"options_page": "options.html",
"background": {
"page": "background.html"
},
"browser_action": {
"default_popup": "index.html"
}
}
P.S.: While we’re at it, I would also clean up the public dir, making sure we keep there only manifest.json and index.html.
3. Create background html and js|ts files
Add public/background.html file with content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
</body>
</html>
Add src/background.js|ts file with content:
export default (function() {
console.log('This should work!!!');
})();
4. Create options html and js|ts files
Add public/options.html file with content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
</body>
</html>
Add src/options.js|ts file with content:
export default (function() {
console.log('This also should work!');
})();
5. Install few helpers
yarn add patch-package postinstall-postinstall webpack-extension-reloader —dev
6. Implement watch script
Add scrips/watch.js file with content:
#!/usr/bin/env node
// A script for developing a browser extension with live-reloading using Create React App (no need to eject).
// Run it instead of the "start" script of your app for a nice development environment.
// P.S.: Install webpack-extension-reloader.
// Force a "development" environment in watch mode
process.env.BABEL_ENV = "development";
process.env.NODE_ENV = "development";
const fs = require("fs-extra");
const paths = require("react-scripts/config/paths");
const webpack = require("webpack");
const configFactory = require("react-scripts/config/webpack.config");
const colors = require("colors/safe");
const ExtensionReloader = require("webpack-extension-reloader");
// Create the Webpack config usings the same settings used by the "start" script of create-react-app.
const config = configFactory("development");
// Add the webpack-extension-reloader plugin to the Webpack config.
// It notifies and reloads the extension on code changes.
config.plugins.push(new ExtensionReloader());
// Start Webpack in watch mode.
const compiler = webpack(config);
const watcher = compiler.watch({}, function(err) {
if (err) {
console.error(err);
} else {
// Every time Webpack finishes recompiling copy all the assets of the
// "public" dir in the "build" dir (except for the background.html, index.html and options.html)
fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true,
filter: file => file !== paths.appHtml && file !== paths.appBackgroundHtml && file !== paths.appOptionsHtml
});
// Report on console the successful build
console.clear();
console.info(colors.green("Compiled successfully!"));
console.info("Built at", new Date().toLocaleTimeString());
console.info();
console.info("Note that the development build is not optimized.");
console.info("To create a production build, use yarn build.");
console.info();
}
});
7. Modify npm scripts
In your package.json add homepage, then modify start scripts add postinstall like this:
{
"homepage": "/",
"scripts": {
"start": "node ./scripts/watch",
"postinstall": "patch-package"
}
}
8. Create patch for react-scripts
In the root of your CRA project, create directory name
patches
along with a filereact-scripts+3.4.1.patch
containing:
diff --git a/node_modules/react-scripts/config/paths.js b/node_modules/react-scripts/config/paths.js | |
index 11d81b7..f095618 100644 | |
--- a/node_modules/react-scripts/config/paths.js | |
+++ b/node_modules/react-scripts/config/paths.js | |
@@ -63,7 +63,11 @@ module.exports = { | |
appBuild: resolveApp('build'), | |
appPublic: resolveApp('public'), | |
appHtml: resolveApp('public/index.html'), | |
+ appBackgroundHtml: resolveApp('public/background.html'), | |
+ appOptionsHtml: resolveApp('public/options.html'), | |
appIndexJs: resolveModule(resolveApp, 'src/index'), | |
+ appBackgroundJs: resolveModule(resolveApp, 'src/background'), | |
+ appOptionsJs: resolveModule(resolveApp, 'src/options'), | |
appPackageJson: resolveApp('package.json'), | |
appSrc: resolveApp('src'), | |
appTsConfig: resolveApp('tsconfig.json'), | |
@@ -85,7 +89,11 @@ module.exports = { | |
appBuild: resolveApp('build'), | |
appPublic: resolveApp('public'), | |
appHtml: resolveApp('public/index.html'), | |
+ appBackgroundHtml: resolveApp('public/background.html'), | |
+ appOptionsHtml: resolveApp('public/options.html'), | |
appIndexJs: resolveModule(resolveApp, 'src/index'), | |
+ appBackgroundJs: resolveModule(resolveApp, 'src/background'), | |
+ appOptionsJs: resolveModule(resolveApp, 'src/options'), | |
appPackageJson: resolveApp('package.json'), | |
appSrc: resolveApp('src'), | |
appTsConfig: resolveApp('tsconfig.json'), | |
@@ -120,7 +128,11 @@ if ( | |
appBuild: resolveOwn('../../build'), | |
appPublic: resolveOwn(`${templatePath}/public`), | |
appHtml: resolveOwn(`${templatePath}/public/index.html`), | |
+ appBackgroundHtml: resolveOwn(`${templatePath}/public/background.html`), | |
+ appOptionsHtml: resolveOwn(`${templatePath}/public/options.html`), | |
appIndexJs: resolveModule(resolveOwn, `${templatePath}/src/index`), | |
+ appBackgroundJs: resolveModule(resolveOwn, `${templatePath}/src/background`), | |
+ appOptionsJs: resolveModule(resolveOwn, `${templatePath}/src/options`), | |
appPackageJson: resolveOwn('package.json'), | |
appSrc: resolveOwn(`${templatePath}/src`), | |
appTsConfig: resolveOwn(`${templatePath}/tsconfig.json`), | |
diff --git a/node_modules/react-scripts/config/webpack.config.js b/node_modules/react-scripts/config/webpack.config.js | |
index 25840d9..6b9e08f 100644 | |
--- a/node_modules/react-scripts/config/webpack.config.js | |
+++ b/node_modules/react-scripts/config/webpack.config.js | |
@@ -43,7 +43,7 @@ const appPackageJson = require(paths.appPackageJson); | |
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; | |
// Some apps do not need the benefits of saving a web request, so not inlining the chunk | |
// makes for a smoother build process. | |
-const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false'; | |
+// const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false'; | |
const isExtendingEslintConfig = process.env.EXTEND_ESLINT === 'true'; | |
@@ -149,35 +149,21 @@ module.exports = function(webpackEnv) { | |
: isEnvDevelopment && 'cheap-module-source-map', | |
// These are the "entry points" to our application. | |
// This means they will be the "root" imports that are included in JS bundle. | |
- entry: [ | |
- // Include an alternative client for WebpackDevServer. A client's job is to | |
- // connect to WebpackDevServer by a socket and get notified about changes. | |
- // When you save a file, the client will either apply hot updates (in case | |
- // of CSS changes), or refresh the page (in case of JS changes). When you | |
- // make a syntax error, this client will display a syntax error overlay. | |
- // Note: instead of the default WebpackDevServer client, we use a custom one | |
- // to bring better experience for Create React App users. You can replace | |
- // the line below with these two lines if you prefer the stock client: | |
- // require.resolve('webpack-dev-server/client') + '?/', | |
- // require.resolve('webpack/hot/dev-server'), | |
- isEnvDevelopment && | |
- require.resolve('react-dev-utils/webpackHotDevClient'), | |
- // Finally, this is your app's code: | |
- paths.appIndexJs, | |
- // We include the app code last so that if there is a runtime error during | |
- // initialization, it doesn't blow up the WebpackDevServer client, and | |
- // changing JS code would still trigger a refresh. | |
- ].filter(Boolean), | |
+ entry: { | |
+ background: paths.appBackgroundJs, | |
+ options: paths.appOptionsJs, | |
+ popup: paths.appIndexJs, | |
+ }, | |
output: { | |
// The build folder. | |
- path: isEnvProduction ? paths.appBuild : undefined, | |
+ path: paths.appBuild, | |
// Add /* filename */ comments to generated require()s in the output. | |
pathinfo: isEnvDevelopment, | |
// There will be one main bundle, and one file per asynchronous chunk. | |
// In development, it does not produce real files. | |
filename: isEnvProduction | |
? 'static/js/[name].[contenthash:8].js' | |
- : isEnvDevelopment && 'static/js/bundle.js', | |
+ : isEnvDevelopment && 'static/js/[name].bundle.js', | |
// TODO: remove this when upgrading to webpack 5 | |
futureEmitAssets: true, | |
// There are also additional JS chunk files if you use code splitting. | |
@@ -558,6 +544,62 @@ module.exports = function(webpackEnv) { | |
], | |
}, | |
plugins: [ | |
+ // Generates an `background.html` file with the <script> injected. | |
+ new HtmlWebpackPlugin( | |
+ Object.assign( | |
+ {}, | |
+ { | |
+ inject: true, | |
+ template: paths.appBackgroundHtml, | |
+ filename: 'background.html', | |
+ excludeChunks: ['popup', 'options'], | |
+ }, | |
+ isEnvProduction | |
+ ? { | |
+ minify: { | |
+ removeComments: true, | |
+ collapseWhitespace: true, | |
+ removeRedundantAttributes: true, | |
+ useShortDoctype: true, | |
+ removeEmptyAttributes: true, | |
+ removeStyleLinkTypeAttributes: true, | |
+ keepClosingSlash: true, | |
+ minifyJS: true, | |
+ minifyCSS: true, | |
+ minifyURLs: true, | |
+ }, | |
+ } | |
+ : undefined | |
+ ) | |
+ ), | |
+ // Generates an `options.html` file with the <script> injected. | |
+ new HtmlWebpackPlugin( | |
+ Object.assign( | |
+ {}, | |
+ { | |
+ inject: true, | |
+ template: paths.appOptionsHtml, | |
+ filename: 'options.html', | |
+ excludeChunks: ['popup', 'background'], | |
+ }, | |
+ isEnvProduction | |
+ ? { | |
+ minify: { | |
+ removeComments: true, | |
+ collapseWhitespace: true, | |
+ removeRedundantAttributes: true, | |
+ useShortDoctype: true, | |
+ removeEmptyAttributes: true, | |
+ removeStyleLinkTypeAttributes: true, | |
+ keepClosingSlash: true, | |
+ minifyJS: true, | |
+ minifyCSS: true, | |
+ minifyURLs: true, | |
+ }, | |
+ } | |
+ : undefined | |
+ ) | |
+ ), | |
// Generates an `index.html` file with the <script> injected. | |
new HtmlWebpackPlugin( | |
Object.assign( | |
@@ -565,6 +607,7 @@ module.exports = function(webpackEnv) { | |
{ | |
inject: true, | |
template: paths.appHtml, | |
+ excludeChunks: ['background', 'options'], | |
}, | |
isEnvProduction | |
? { | |
@@ -587,9 +630,9 @@ module.exports = function(webpackEnv) { | |
// Inlines the webpack runtime script. This script is too small to warrant | |
// a network request. | |
// https://github.com/facebook/create-react-app/issues/5358 | |
- isEnvProduction && | |
- shouldInlineRuntimeChunk && | |
- new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]), | |
+ // isEnvProduction && | |
+ // shouldInlineRuntimeChunk && | |
+ // new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]), | |
// Makes some environment variables available in index.html. | |
// The public URL is available as %PUBLIC_URL% in index.html, e.g.: | |
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico"> | |
@@ -638,9 +681,12 @@ module.exports = function(webpackEnv) { | |
manifest[file.name] = file.path; | |
return manifest; | |
}, seed); | |
- const entrypointFiles = entrypoints.main.filter( | |
- fileName => !fileName.endsWith('.map') | |
- ); | |
+ const entrypointFiles = {}; | |
+ for (const property in entrypoints) { | |
+ entrypointFiles[property] = entrypoints[property].filter( | |
+ fileName => !fileName.endsWith('.map') | |
+ ); | |
+ } | |
return { | |
files: manifestFiles, | |
diff --git a/node_modules/react-scripts/scripts/build.js b/node_modules/react-scripts/scripts/build.js | |
index fa30fb0..07998b1 100644 | |
--- a/node_modules/react-scripts/scripts/build.js | |
+++ b/node_modules/react-scripts/scripts/build.js | |
@@ -222,6 +222,6 @@ function build(previousFileSizes) { | |
function copyPublicFolder() { | |
fs.copySync(paths.appPublic, paths.appBuild, { | |
dereference: true, | |
- filter: file => file !== paths.appHtml, | |
+ filter: file => file !== paths.appHtml && file !== paths.appBackgroundHtml && file !== paths.appOptionsHtml, | |
}); | |
} |
Alternatively you can modify the react-scripts yourself
Have a look at this gist to see how you can
modify and patch react-scripts to use CRA for your future browser extension. It’s quite simple, just make the modification to the existing
react-scripts
inside your node_modules
, then run:
npx patch-package react-scripts
9. Patch react-scripts & start the app
Run
yarn install
thenyarn start
oryarn build
10. Open your extension
open
chrome://extensions/
and usingLoad unpacked
select thebuild
folder from the root of your CRA project.
You should see a new popup
icon as well as be able to inspect background.html
.
In addition, if you got into the Details
of your extension, you will be able to see Extension options
too.
Acknowledgments
Big thanks goes to Matteo for his Developing a browser extension with Create React App article from which this idea was born.