A webpack plugin for developing your userscripts with a modern workflow.
Focusing on support for Tampermonkey and Violentmonkey.
- Features
- Installation
- Quick start
- API / Configuration
- Meta generation
- External dependencies (@require)
- External assets (@resource)
- CSS
- TypeScript
- Working with HMR
- Comparison with vite-plugin-monkey
- Development Notes
- HMR (Hot Module Replacement): Easily apply changes without page reload.
- CSP bypassing: No worries about CSP restrictions during development.
- Meta generation: Generate userscript meta blocks programmatically.
- Multiple userscripts: Develop multiple userscripts at the same time.
- Clean output: Comply with the userscript hosting sites' no-minification requirement.
The modern workflow also allows:
- Compiling: Use the latest JavaScript features and even TypeScript.
- Code splitting: Split your code into multiple files, and share code between userscripts.
npm install webpack-monkey
# peer dependencies
npm install webpack webpack-dev-server
Here is a preview of the final file structure:
.
├── dist
│ └── hello.user.js
├── src
│ ├── index.js
│ └── meta.js
├── webpack.config.js
└── package.json
Create a new project and initialize it with npm:
npm init -y
npm install webpack webpack-cli webpack-dev-server webpack-monkey
Create src/index.js
:
GM_log("Hello world!")
// enable HMR, for more details please check the HMR section below
if (module.hot) {
module.hot.monkeyReload()
}
Create src/meta.js
:
module.exports = {
name: "Hello world",
version: "1.0.0",
match: ["*://example.com/"],
}
Create webpack.config.js
:
const path = require("path")
const { monkey } = require("webpack-monkey")
module.exports = monkey({
entry: {
hello: "./src/index.js",
},
output: {
path: path.resolve(__dirname, "dist"),
},
})
Finally, add the following scripts to package.json
:
{
"scripts": {
"dev": "webpack serve --mode development",
"build": "webpack --mode production"
}
}
Run npm run dev
. When the dev server is ready, you should see a message like this:
[MonkeyPlugin] Dev script hosted at: http://localhost:xxxx/monkey-dev.user.js
Now open the URL in your browser and install the dev script.
Important
Unless the dev server's port has changed, you don't need to reinstall the dev script after running npm run dev
next time.
Go to http://example.com
and open the console, you should see the message "Hello world!"
.
Now edit index.js
and try to change the message text in GM_log()
, save the file, and you'll see the new message printed in the console without page reload!
Run npm run build
, and the userscript will be built to dist/hello.user.js
:
// ==UserScript==
// @name Hello world
// @grant GM_log
// @match *://example.com/
// @version 1.0.0
// ==/UserScript==
;(() => {
GM_log("Hello, world!")
})()
Note that the GM_log
function is automatically added to @grant
.
Set up your project as follows to develop multiple userscripts at the same time:
.
├── dist
│ ├── foo.user.js
│ └── bar.user.js
├── src
│ ├── foo
│ │ ├── index.js
│ │ └── meta.js
│ └── bar
│ ├── index.js
│ └── meta.js
├── webpack.config.js
└── package.json
And update webpack.config.js
as:
const path = require("path")
const { monkey } = require("webpack-monkey")
module.exports = monkey({
entry: {
foo: "./src/foo/index.js",
bar: "./src/bar/index.js",
},
output: {
path: path.resolve(__dirname, "dist"),
},
})
Now you can run npm run dev
and both userscripts will be served, no need to install the dev scripts again.
- This project's examples
- This project's playground
- My own userscripts repo: userscripts (in pure TypeScript)
Takes a webpack config object and returns a cloned config object with plugins added and some options modified.
Note
The followings are some major changes to the config, for more details please check the source code.
- Adds some sensible defaults for userscript development.
- Adds
MonkeyPlugin
to the plugins list. - Replaces the default minimizer
TerserPlugin
withMonkeyMinimizer
, which extendsTerserPlugin
with some extra features.
The monkey options are passed as the monkey
property of the webpack config object:
module.exports = monkey({
// normal webpack options
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
},
// monkey options
monkey: {
debug: true,
},
})
Options
debug
meta.resolve
meta.load
meta.transform
require.provider
require.lockVersions
require.exportsFromUnnamed
require.resolve
devScript.meta
devScript.transform
beautify.prettier
terserPluginOptions
You'll find some options that can be a function with a context object as the second argument. The context object has the following type:
interface OptionFunctionContext {
logger: WebpackLogger | Console
}
Type: boolean
Default: false
When enabled, some debug messages will be printed in the console (just a few for now).
Type: string | string[] | (arg: { entryName: string; entry: string }, context) => string | undefined | Promise<string | undefined>
Default: ["meta.js", "meta.ts", "meta.json"]
The path of meta file to be used by meta.load
later. If an array, the first file that matches will be used.
You can pass a custom function as the resolver:
monkey({
monkey: {
meta: {
resolve({ entry }) {
return path.resolve(path.dirname(entry), "meta.txt")
// if undefined, this entry will not be treated as a userscript
// return undefined
},
},
},
})
Type: (arg: { file: string }, context) => UserscriptMeta | Promise<UserscriptMeta>
Default: require()
Function to load the meta file and return the meta object. The default function uses require()
to load the meta file with supported extensions: .js
, .ts
, .json
.
monkey({
monkey: {
meta: {
load({ file }) {
// read JSON from a meta.txt given by the above meta.resolve example
return JSON.stringify(fs.readFileSync(file, "utf-8"))
},
},
},
})
Type: (arg: { meta: UserscriptMeta }, context) => UserscriptMeta | Promise<UserscriptMeta>
Default: undefined
Function to transform the meta object before using it for serving or building. Can be used to add or modify meta properties.
Type: "jsdelivr" | "unpkg"
Default: "unpkg"
When importing modules using webpack externals, the module will be resolved to // @require <CDN>/<moduleName>@<version>
, where <CDN>
is the provider's URL prefix (for example https://cdn.jsdelivr.net/npm
), and <version>
is the version of the installed package, or, if not found, the version range specified in the project's package.json
(can be controlled with require.lockVersions
).
Type: boolean
Default: true
When using a CDN provider, this option controls whether to generate URLs with the versions of installed packages, or the version ranges specified in the project's package.json
. For example:
false
:https://unpkg.com/jquery@^3.5.0
(version range specified inpackage.json
)true
:https://unpkg.com/[email protected]
(version actually installed)
Type: boolean
Default: false
When importing an external module with URL, whether to allow using its exports without specifying a global variable name for it. For example:
import { ajax } from "https://unpkg.com/jquery"
The above code will cause a runtime error because a global variable ($
) is not specified along with the URL. In this case, webpack-monkey will print a warning in development mode and throw an error in production mode. Setting this option to true
suppresses the warning and error.
Type:
type RequireResolver = (
arg: {
name: string // the module name
externalType: string // you would't need this if you don't know what it is
version?: string // the installed version, or undefined if not found
packageVersion?: string // the version specified in package.json, or undefined if not found
url?: string // the URL if the module is imported with URL, otherwise undefined
},
context: object,
) => string | undefined | Promise<string | undefined>
Default: undefined
Custom resolver for external dependencies. The function should return a URL string, or undefined if the module should not produce a @require
.
monkey({
monkey: {
require: {
resolve({ name, url }) {
if (name.includes("dev-tools")) {
return undefined
}
return url || "https://unpkg.com/" + name
},
},
},
})
Type: UserscriptMeta | ((arg: { meta: UserscriptMeta }) => UserscriptMeta)
Default: undefined
Meta object for the dev script. If an object, it will be merged with the default meta object; if a function, it will be called with the default meta object as the argument, and the returned object will be used as a replacement.
Type: (arg: { content: string }, context) => string
Default: undefined
Function to transform the dev script content before serving. Can be used to add or modify the script content.
Type: boolean
Default: undefined
When enabled, the output will be formatted using Prettier with your Prettier config. When undefined, it will be enabled if Prettier is found installed.
Type: object
Default: undefined
Custom options for the minimizer TerserPlugin
.
Works the same as module.hot.accept()
, except that it'll reload the whole userscript instead of the changed module and the modules that depend on it. See Working with HMR section for more details.
Options
Type: (string | RegExp)[] | (moduleId: string | number) => boolean
Default: ["node_modules"]
Modules to ignore when reloading. Can be a list of strings or regular expressions, or a function that returns a boolean.
Note that this only affects the modules that webpack-monkey tries to additionally reload, and does not affect the modules that webpack would reload according to its own rules.
The meta object is provided in a separate file. By default, webpack-monkey will look for meta.js
, meta.ts
, or meta.json
in the same directory as the entry file, and load it with require()
.
You can customize this behavior with the monkey.meta.resolve
and monkey.meta.load
options.
Note
The meta file is evaluated in the Node.js environment, so you can require()
other modules in it, and cannot use browser APIs such as window
.
The meta fields have different types as shown below:
Type | Fields | Example | Example output |
---|---|---|---|
Boolean | noframes |
module.exports = {
noframes: true,
} |
// @noframes |
Array | grant match include exclude require resource connect webRequest |
module.exports = {
match: [
"*://example.com/",
"*://example.org/"
],
// can be a string if only one item
require: "https://example.com/foo.js",
// empty array will be omitted
resource: [],
} |
// @match *://example.com/
// @match *://example.org/
// @require https://example.com/foo.js |
Object (I18n) |
name description |
module.exports = {
name: {
default: "Hello world",
"zh-CN": "ä˝ ĺĄ˝ä¸–ç•Ś",
},
// can be a string if only one item
description: "Say hello to the world!",
} |
// @name Hello world
// @name:zh-CN ä˝ ĺĄ˝ä¸–ç•Ś
// @description Say hello to the world! |
String | All the others |
module.exports = {
version: "0.1",
} |
//@version 0.1 |
There are several ways to handle external dependencies, please choose the one that suits you best.
- meta.require (simple)
- import with URL (good for tree-shaking)
- Webpack externals with URL (good for tree-shaking and TypeScript) <- recommended
- Webpack externals with global variable (most flexible)
The simplest way is to put the URL in the require
meta property:
// meta.js
module.exports = {
require: [
"https://unpkg.com/[email protected]",
// to load a specific file instead of the default entry, specify its full path
"https://unpkg.com/[email protected]/lodash.min.js",
],
}
// index.js
$(".foo").text(_.capitalize("hello world"))
You can directly import an external script with URL:
// index.js
import "https://unpkg.com/[email protected]"
$(".foo")
You can also use other import forms. When using an import form other than Side effect import, the external script will be treated as a module, and you must provide a global variable name to reference the module in the format of "<globalVar>@<URL>"
, for example:
import jq, { ajax } from "$@https://unpkg.com/[email protected]"
jq(".foo").click(() => ajax("/api"))
The above code is roughly equivalent to the following:
import "https://unpkg.com/[email protected]"
const jq = $
const { ajax } = $
... except that the global variable $
is treated as a module object, so webpack generates a shim for it - not much a thing to worry about though.
Note that you don't need to specify an import name that is different from the global variable name, because webpack will (always) rename it to a longer form. For example, you can write import $ from "$@..."
, and webpack will generate an output like const external_$_namespaceObject = $
.
// webpack.config.js
module.exports = {
externals: {
// same rule as in [2. import with URL], specify a global variable name if needed
jquery: "https://unpkg.com/[email protected]",
lodash: "_@https://unpkg.com/lodash",
},
}
// index.js
import "jquery"
import _ from "lodash"
// webpack.config.js
module.exports = {
externals: {
jquery: "$",
},
}
// index.js
import "jquery"
// or
import $ from "jquery"
In this case, the module will be resolved to a URL according to the require
option. If require.resolve
is specified, it will be used; otherwise, a CDN provider will be used according to require.provider
.
Not well supported yet, coming soon. For now, you can put the asset URLs in the meta object, and manually fetch them during development:
// meta.js
module.exports = {
resource: ["myText https://example.com/my-text.txt"],
}
// index.js
async function main() {
const myText =
process.env.NODE_ENV === "development"
? await fetch("https://example.com/my-text.txt").then((res) => res.text())
: GM_getResourceText("myText")
console.log(myText)
}
You can import CSS files in your js files (check out webpack's guide), and webpack-monkey will bundle them into the userscript:
index.js
import "./styles.css"
GM_log("Hello world!")
styles.css
body {
color: red;
}
dist/hello.user.js
// ==UserScript==
// @name Hello world
// @grant GM_log
// @grant GM_addStyle
// @match *://*/*
// @version 1.0.0
// ==/UserScript==
;(() => {
GM_log("Hello, world!")
})()
GM_addStyle(`
body {
color: red;
}
`)
The CSS content will be wrapped in a GM_addStyle()
at the end of the userscript, so if you or your users are inspecting the code, you can see the JavaScript code from the beginning, without having to scroll over a massive CSS block.
Bonus: when writing styles for your custom DOM elements, a good practice is to use CSS Modules, which ensures that your class names will not conflict with other userscripts or the page itself. Check out webpack's guide.
TypeScript is supported out of the box. Just set up the TypeScript environment as usual, and you're good to go. Check out webpack's guide if you're not familiar with it.
Note that only ts-loader
and babel-loader
are tested. Other loaders are supposed to work, otherwise please let me know by opening an issue.
Bonus: install @types/tampermonkey
to get the types for GM_*
.
You can place the meta object in a meta.ts
as well:
// note: Meta is an alias of UserscriptMeta
import { Meta } from "webpack-monkey"
export default {
version: "1.0",
name: "Hello world",
} satisfies Meta
However, since this TypeScript file will be loaded with require()
, you'll need to set up your environment to support it. Here's an example using ts-node:
-
Install
ts-node
:npm install ts-node
-
In your
tsconfig.json
, setesModuleInterop: true
and add ats-node
object that setsmodule: "commonjs"
:{ "compilerOptions": { "esModuleInterop": true }, "ts-node": { "compilerOptions": { "module": "commonjs" } } }
-
Do either of the following:
-
Rename
webpack.config.js
towebpack.config.ts
(maybe need some rewriting), then webpack will do the rest for you. -
Install
cross-env
. Then in yourpackage.json
, prepend a node flag to the webpack commands:{ "scripts": { - "dev": "webpack serve --mode development", - "build": "webpack --mode production" + "dev": "cross-env NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --mode development", + "build": "cross-env NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --mode production" } }
-
If you find the above method too complicated, you can also use a meta.js
with JSDoc comments:
/**
* @type {import("webpack-monkey").Meta}
*/
const meta = {
name: "Hello world",
version: "1.0.0",
}
module.exports = meta
If you're using WebStorm, the JSDoc type checking seems to be supported out of the box so you're good to go.
If you're using VSCode, some extra steps are needed.
To type an external dependency, you are most likely to locally install it (npm install xxx
), or if the types are not built-in, install only its types package (@types/xxx
).
Then, if using the #3 or #4 method of handling external dependencies, TypeScript will automatically recognize the types when imported:
import $ from "jquery"
And if using #1 or #2, you need to manually declare the global variables:
/// <reference types="jquery" />
declare global {
var $: JQueryStatic
// you can also use an inline import
var mitt: typeof import("mitt").default
}
$(".foo")
mitt()
If you don't know what HMR is, check out webpack's introduction.
webpack-monkey extends webpack's HMR API with module.hot.monkeyReload()
to reload all the modules in a userscript when any of them is changed.
module.hot.monkeyReload()
should be added to each userscript's entry file, and then you can clear side effects with module.hot.dispose()
in any dependent modules. Example:
index.js
import { foo } from "./foo"
const element = $("<div>").text(foo).appendTo("body")
if (module.hot) {
module.hot.monkeyReload()
module.hot.dispose(() => {
element.remove()
})
}
foo.js
import { bar } from "./bar"
export const foo = "foo"
bar()
bar.js
export function bar() {
const timer = setInterval(() => console.log(Date.now()), 1000)
if (module.hot) {
module.hot.dispose(() => {
clearInterval(timer)
})
}
}
When developing userscripts, you'll most likely want to enable HMR to prevent page reloads, because you have no direct access to the target page's states and will lose them all when reloading, for example the position in an infinite scroll list, which is very annoying and slows down the development.
You've probably already used some frameworks such as React and Vue that have the HMR support built in for you, and it's pretty straightforward to change your components' code and see the changes applied without page reload. However, if you change the code outside of components, a full page reload is performed because webpack cannot magically clear the outdated code's (possible) side effects.
So how to prevent full reloads without such frameworks? Webpack has a HMR guide for the standard method, but it's too complicated to understand, and too hard to set up without making mistakes.
Let's take the example from that guide to start with:
import _ from "lodash"
import printMe from "./print.js"
function component() {
// ...
return element
}
let element = component() // Store the element to re-render on print.js changes
document.body.appendChild(element)
if (module.hot) {
module.hot.accept("./print.js", function () {
console.log("Accepting the updated printMe module!")
document.body.removeChild(element)
element = component() // Re-render the "component" to update the click handler
document.body.appendChild(element)
})
}
This works, but accepting dependencies is super tedious and error-prone, because you have to figure out each dependency's side effects and the way to clear them, and you have to write each dependency's path without the help of IDE's auto-completion and auto-renaming, which is a nightmare for maintenance.
So a better way is to self-accept the current module and only clear itself's side effects:
if (module.hot) {
- module.hot.accept("./print.js", function () {
- console.log("Accepting the updated printMe module!")
- document.body.removeChild(element)
- element = component() // Re-render the "component" to update the click handler
- document.body.appendChild(element)
- })
+ module.hot.accept()
+ module.hot.dispose(() => {
+ document.body.removeChild(element)
+ })
}
But some other problems arise. Let's take another example:
index.js
import { onResize } from "./helper"
onResize(() => console.log("resized"))
if (module.hot) {
module.hot.accept()
}
helper.js
export function onResize(listener) {
window.addEventListener("resize", listener)
}
How to clear this side effect? It happens inside helper.js
, but we cannot clear it with a .dispose()
there, because if index.js
is updated, then helper.js
will not be reloaded, and its .dispose()
will not be called. So we need to hoist this responsibility onto index.js
:
index.js
import { onResize } from "./helper"
- onResize(() => console.log("resized"))
+ const offResize = onResize(() => console.log("resized"))
if (module.hot) {
module.hot.accept()
+ module.hot.dispose(() => {
+ offResize()
+ })
}
helper.js
export function onResize(listener) {
window.addEventListener("resize", listener)
+ return () => {
+ window.removeEventListener("resize", listener)
+ }
}
This is still quite annoying:
- When building for release, the
return () => ...
part is unused, but still bundled into the production code, making the size unnecessarily large. - If
onResize()
is called multiple times, we'll have to keep track of all the returned functions and call them all in.dispose()
. - If we want
onResize()
to return something else, we'll have to make other dirty workarounds.
webpack-monkey provides a simple solution for this by extending webpack's HMR API. You only need to put these few lines in your userscript's entry file:
if (module.hot) {
module.hot.monkeyReload()
}
module.hot.monkeyReload()
works the same as module.hot.accept()
except that it'll reload the whole userscript instead of the changed module and the modules that depend on it, as shown below:
This means you no longer have to worry about the relationship between modules, you only focus on clearing the side effects for each individual module.
With this feature, we can rewrite the above example as:
index.js
import { onResize } from "./helper"
- const offResize = onResize(() => console.log("resized"))
+ onResize(() => console.log("resized"))
if (module.hot) {
- module.hot.accept()
- module.hot.dispose(() => {
- offResize()
- })
+ module.hot.monkeyReload()
}
helper.js
export function onResize(listener) {
window.addEventListener("resize", listener)
- return () => {
- window.removeEventListener("resize", listener)
- }
+ if (module.hot) {
+ module.hot.dispose(() => {
+ window.removeEventListener("resize", listener)
+ })
+ }
}
Now helper.js
will be reloaded when index.js
is updated, so we can place the cleanup code immediately after the side effect code, which is very intuitive and easy to maintain.
The if (module.hot)
block will also be removed when building for release, so no more unused code.
vite-plugin-monkey is another great plugin for developing userscripts but with Vite. This plugin and webpack-monkey basically share the same goal - to develop userscripts with bundling and HMR support, but they have different approaches.
Vite is a next-generation build tool and is faster than webpack. However, Vite has a limitation that it only emits ES modules in development mode, meaning that they must be loaded with <script type="module">
, which will be blocked by the page's CSP if it has one. A notable example of CSP-enabled sites is github.com
.
There is a workaround though - you can disable CSP with a browser extension. But it's a risky move because CSP is a security feature and is there for a reason, also you may forget to re-enable it after development. More importantly, if you are maintaining an open-source userscript, your contributors will be required to disable CSP as well, which is not a good experience.
Webpack, on the other hand, emits CommonJS modules in development mode, which are capable to be evaluated in userscript scope and will never be affected by CSP.
vite-plugin-monkey is quite a mature project and has been well tested by the community. It has some features that webpack-monkey doesn't have yet, such as:
- Loading external assets (@resource)
- Greasemonkey support (GM.*)
- Generating
*.meta.js
for lightweight update checking when self-hosting userscripts, e.g. GitHub Pages
webpack-monkey is still in early development but has some exclusive features:
- CSP bypassing
- Developing multiple userscripts by installing a single dev script
- Reloading userscripts instead of the page when performing HMR with side effects
I have to put this here because I guess this kind of thought can easily come to one's mind.
The answer is no. They have many similar concepts and features, but these are some features that a decent userscript development tool should have. It's like all planets are round.
In fact, a bit earlier than vite-plugin-monkey, I once spent a lot of time using Vite to set up a development environment for my own userscripts, which was somewhat inspired by rollup-userscript-template. It worked well until I found the CSP issue when developing a userscript for GitHub, and I was very frustrated because there's no choice but to disable CSP. So I migrated the framework to webpack and eventually decided to extract the code as a plugin.
Okay but, is the name a plagiarism? I don't think so. I was thinking about webpack-userscript, but apparently it's already taken by another plugin (with quite different features so I'm not writing a comparison here). Then I came up with webpack-monkey, and then searched for name conflicts and discovered vite-plugin-monkey. I was a bit surprised but I think it's just a coincidence and decided to keep the name as it sounds good. I don't like monkeys though. I like cats.