Advanced Config for CRXJS Vite Plugin
Tags: typescript, webdev, vite, react
Published: 2022-04-12 21:17:53

The CRXJS Vite plugin has been in beta long enough to get a good sample of developer use cases. Thanks to everyone who has helped by creating issues and participating in discussions! Your input is super valuable. 

These are some answers to the most common developer questions we've fielded. I'm Jack Steam, creator of CRXJS. So, if you're looking to add extra HTML pages, extend the manifest at build time, use the new Chrome Scripting API, and inject main world scripts, read on!

### Table of Contents

- [Extra HTML pages](#extra-html-pages)
- [Dynamic manifest with TypeScript](#dynamic-manifest-with-typescript)
- [Manifest icons and public assets](#manifest-icons-and-public-assets)
- [Web accessible resources](#web-accessible-resources)
- [Dynamic content scripts](#dynamic-content-scripts) 
- [Injected main world scripts](#main-world-scripts)

## Extra HTML pages

It's pretty common for an extension to have web pages that you can't declare in the manifest. For example, you might want to change the popup once the user signs in or open a welcome page when the user installs the extension. In addition, devtool extensions like React Developer Tools don't declare their inspector panels in the manifest.

Given the following file structure and manifest, `index.html` and `src/panel.html` will be available during development but not in a production build. We can fix this in `vite.config.ts`.

```
.
├── vite.config.ts
├── manifest.json
├── index.html
└── src/
    ├── devtools.html
    └── panel.html
```
```jsonc
// manifest.json
{
  "manifest_version": 3,
  "version": "1.0.0",
  "name": "example",
  "devtools_page": "src/devtools.html"
}
```

To build extra HTML pages, follow the pattern from the Vite Documentation for [Multi-Page Apps](https://vitejs.dev/guide/build.html#multi-page-app): 

```javascript
// vite.config.js
import { resolve } from 'path';
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';

export default defineConfig({
  build: {
    rollupOptions: {
      // add any html pages here
      input: {
        // output file at '/index.html'
        welcome: resolve(__dirname, 'index.html'),
        // output file at '/src/panel.html'
        panel: resolve(__dirname, 'src/panel.html'),
      },
    },
  },
  plugins: [crx({ manifest })],  
});
```

## Dynamic manifest with TypeScript

CRXJS treats the manifest as a config option and transforms it during the build process. Furthermore, since the manifest is a JavaScript Object, it opens up some exciting ways to extend it. 

Imagine writing your manifest in TypeScript. Use different names for development and production. Keep the version number in sync with `package.json`. 🤔 

The Vite plugin provides a `defineManifest` function that works like Vite's `defineConfig` function and provides IntelliSense, making it easy to extend your manifest at build time.

```typescript
// manifest.config.ts

import { defineManifest } from '@crxjs/vite-plugin'
import { version } from './package.json'

const names = {
  build: 'My Extension',
  serve: '[INTERNAL] My Extension'
}

// import to `vite.config.ts`
export default defineManifest((config, env) => ({
  manifest_version: 3,
  name: names[env.command],
  version,
}))
```

## Manifest icons and public assets

If you've used Vite for a website, you might be familiar with [the public directory](https://vitejs.dev/guide/assets.html#the-public-directory). Vite copies the contents of `public` to the output directory. 

You can refer to public files in the manifest. If CRXJS doesn't find a matching file in `public`, it will look for the file relative to [the Vite project root](https://vitejs.dev/config/#root) and add the asset to the output files.

You're free to put your icons in `public` or anywhere else that makes sense!

```jsonc
// manifest.json 
{
  "icons": {
    // from src/icons/icon-16.png
    "16": "src/icons/icon-16.png",
    // from public/icons/icon-24.png 
    "24": "icons/icon-24.png"
  },
  "web_accessible_resources": [{
    matches: ['https://www.google.com/*'],
    // copies all png files in src/images
    resources: ["src/images/*.png"]
  }]
}
```

The plugin will also copy files that match [globs](https://github.com/mrmlnc/fast-glob#pattern-syntax) in `web_accessible_resources`. 

CRXJS ignores the globs `*` and `**/*`. You probably don't want to copy `package.json` and everything in `node_modules`. The real question is, should a website have access to every single file in your extension? 

What are web-accessible resources, anyways?

## Web Accessible Resources

The files in your Chrome Extension are private by default. So, for example, if your extension has the file `icon.png`, extension pages can access it, but random websites cannot (it's not a web-accessible resource). If you want an extension resource to be web-accessible, you need to declare the file in the manifest under `web_accessible_resources`.

What if I want to use an image in a content script? It has to be web-accessible. Why? Content scripts share the origin of the host page, so a web request from a content script on `https://www.google.com` is the same as a request from `https://www.google.com` itself.

It can get tedious to update the manifest with every file you're using. We're using build tools, so why do more manual work than necessary? When you import an image into a content script, CRXJS updates the manifest automatically. ✨

All you need to do is wrap the import path with a call to `chrome.runtime.getURL` to generate the extension URL: 

```javascript
import logoPath from './logo.png'

const logo = document.createElement('img')
logo.src = chrome.runtime.getURL(logo)
```

These files are accessible anywhere the content script runs. Also, these assets use [a dynamic url](https://developer.chrome.com/docs/extensions/mv3/manifest/web_accessible_resources/#:~:text=access%20the%20resources.-,use_dynamic_url,-If%20true%2C%20only), so malicious websites can't use it to fingerprint your extension!

## Dynamic Content Scripts

The [Chrome Scripting API](https://developer.chrome.com/docs/extensions/reference/scripting) lets you execute content scripts from the background of a Chrome Extension. 

The manifest doesn't have a place to declare dynamic content scripts, so how do we tell Vite about them? Of course, we could add them to the Vite config like an extra HTML page, but how does CRXJS know that we intend the added script to be a content script? Does it need the unique flavor of HMR that CRXJS provides? What about web-accessible resources?

CRXJS uses a unique import query to designate that an import points to a content script. When an import name ends with the query `?script`, the default export is the output filename of the content script. You can then use this filename with the Chrome Scripting API to execute that content script and profit from Vite HMR.

```javascript
import scriptPath from './content-script?script'

chrome.action.onClicked.addListener((tab) => {  
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: [scriptPath]
  });
});
```

The resources of a dynamic content script are available to all URLs by default, but you can tighten that up using the `defineDynamicResource` function:

```typescript
import { defineManifest, defineDynamicResource } from '@crxjs/vite-plugin'

export default defineManifest({
  ...manifest,
  web_accessible_resources: [
    defineDynamicResource({
      matches: ['https://www.google.com/*'],
    })
  ]
})
```

## Main world scripts

Content scripts [run in an isolated world](https://developer.chrome.com/docs/extensions/mv3/content_scripts/#isolated_world), but sometimes a script needs to modify the execution environment of the host page. Content scripts usually do this by adding a script tag to the DOM of their host page. The main world script must be web-accessible like any other content script asset.

A dynamic content script import gets us close, but a script imported using `?script` includes a loader file that adds Vite HMR. Unfortunately, the loader relies on the Chrome API only available to content scripts; it won't work in the host page execution environment. What we need is a simple ES module.

You can skip the loader file using the `?script&module` import query:

```javascript
// content-script.ts
import mainWorld from './main-world?script&module'

const script = document.createElement('script')
script.src = chrome.runtime.getURL(mainWorld)
script.type = 'module'
document.head.prepend(script)
```

Now get out there and read global variables, reroute fetch requests, and decorate class prototypes to your heart's content!

## Roadmap

The next thing on CRXJS's roadmap is proper documentation and a better release process. But, don't worry, we're not done adding features and fixing bugs; you can look forward to Shadow DOM in content scripts and better Vue support. I'm also incredibly excited about adding official support for Svelte and Tailwind!

If CRXJS has improved your developer experience, please consider [sponsoring me on GitHub](https://github.com/sponsors/jacksteamdev) or [give me a shoutout on Twitter](https://twitter.com/JackSteamDev). See you next time.