userscript-hot-reload
v0.3.0
Published
A small dev server that hot-reloads userscripts in place — works with any build tool (Tampermonkey / Violentmonkey)
Maintainers
Readme
userscript-hot-reload
Iterating on a userscript gets tedious: change the source, rebuild, reinstall it, refresh the page, find your place again. file:// helps, but it's still painful when the script runs on a heavy page that takes a second to reload.
This small dev server eliminates this loop. Edit your source, save — the script on the page reloads. No page refresh, no reinstall, no copy-pasting builds.

It works with whatever build tool you already use (Vite, webpack, Rollup, esbuild, tsc, or none at all) and any userscript manager that supports loaders (Tampermonkey, Violentmonkey).
edit source ──▶ build rebuilds ──▶ the script reloads on the page
(~0.5s, no page refresh)Quick start
In your userscript project — the directory where your build writes dist/*.user.js — run:
npx userscript-hot-reloadIt starts with no config and prints the rest: the dashboard URL, a one-click Install loader for each script, and what to do next. Follow that.
Installation
npm install --save-dev userscript-hot-reload # per project (recommended)
npm install -g userscript-hot-reload # or globalAdd to package.json:
{
"scripts": {
"dev": "userscript-hot-reload",
"watch": "vite build --watch"
}
}ushr is available as a short CLI alias.
Configuration
Config is optional. Create one with userscript-hot-reload init, or rely on zero-config discovery. Supported files: userscript.config.{yml,yaml,js,mjs,cjs,json}.
# userscript.config.yml — everything below is optional
watchDir: ./dist # where your build writes *.user.js
port: 8765 # loaders embed this URL — reinstall loaders if changed
buildCommand: npm run watch # auto-started/stopped with the server
useGmFetch: true # poll transport (default). false = page fetch() — see "transports" below
scripts: # override/extend what's parsed from the build output
- file: my-script.user.js
name: My Script
matches:
- https://example.com/*
grants:
- GM_setValue
reload: eval # eval (default) | page — full page reload on change
hud: true # on-page reload toast
useGmFetch: false # this script only: page fetch() transport (snappier; see transports below)Or in JS:
// userscript.config.js
import { defineConfig } from 'userscript-hot-reload';
export default defineConfig({
watchDir: './dist',
buildCommand: 'vite build --watch',
});How config and script metadata merge
For every script, the server parses the built file's ==UserScript== block and merges it with your config — config wins, the header fills every gap. Since your bundler (e.g. vite-plugin-monkey) already writes the full header, most projects need no per-script config at all. Add a script entry only to override something (extra @match for dev, a different reload mode, …).
All options
| Option | Default | Description |
| -------------------------------------------------------| -----------------------------| -----------------------------------------------------------------------------------------------------------------------------------|
| watchDir | auto (dist/build/out) | Directory containing built *.user.js files |
| port | 8765 | Dev server port |
| host | 127.0.0.1 | Bind address (0.0.0.0 to test from other devices) |
| buildCommand | — | Shell command auto-started with the server (use a --watch build) |
| cwd | config dir | Working directory for buildCommand |
| longPollTimeoutMs | 1000 | How long the server holds an on-update request (also the upper bound on how long a refreshed page waits before its script runs) |
| useGmFetch | true | Global default poll transport. true = GM.xmlHttpRequest (reliable, default); false = page fetch() (snappier, may prompt / fail on strict CSP — same as --fetch). Override per script. |
| scripts[].file | — | Built userscript filename (aliases: watchFile, output) |
| scripts[].name | from header | Display name |
| scripts[].matches | from header | @match patterns for the loader |
| scripts[].grants | from header | Extra @grants for the loader |
| scripts[].connects | from header | Extra @connects for the loader |
| scripts[].icon / namespace / runAt / noframes | from header | Loader metadata |
| scripts[].reload | eval | eval = re-execute in place; page = full page reload |
| scripts[].hud | true | Show the on-page “⚡ reloaded” toast |
| scripts[].useGmFetch | inherits useGmFetch | Per-script transport override (true = GM, false = page fetch()) |
CLI
userscript-hot-reload [command]
Commands: (default) start · init · help
Options: -c/--config -p/--port -d/--dir -b/--build --no-build
--host --fetch --open --no-takeover -v/--version -h/--help
--fetch poll via page fetch() instead of GM.xmlHttpRequest (snappier; may prompt / fail on strict CSP)Restarting is painless: a new instance detects a stale userscript-hot-reload on the same port and takes it over gracefully (no EADDRINUSE, no manual kill).
How it works
┌──────────────┐ writes ┌──────────────┐ watches ┌─────────────────┐
│ your build │ ──────────▶ │ dist/*.js │ ◀────────── │ dev server │
│ (vite/webpack│ │ .user.js │ │ localhost:8765 │
│ /rollup/…) │ └──────────────┘ └────────┬────────┘
└──────────────┘ │ long-poll
▼
┌─────────────────────────────┐
│ loader userscript (installed │
│ once via Tampermonkey) │
│ 1. fetch script → eval │
│ 2. poll ?on-update&v=<hash> │
│ 3. on change: dispose old, │
│ eval new, show HUD ⚡ │
└─────────────────────────────┘A few details that keep it from breaking in everyday use:
- Directory watching (not file watching) catches atomic writes from modern build tools.
- Content hashing — clients poll with their current version (
?v=<hash>). A change that lands between two polls is delivered on the next poll; rebuilds with identical output don't trigger a reload. - Long-poll requests are held briefly (~1s), then answered
204and re-issued. The hold is kept short on purpose: with the defaultGM.xmlHttpRequesttransport, the userscript manager funnels every tab's requests through a single shared connection per host (measured: effective concurrency 1), so a held poll blocks other tabs' requests — including a refreshed page's first script fetch — until it ends. The hold is therefore the upper bound on that wait. Edits still reload instantly — a save resolves a waiting poll immediately. Dropped connections (proxies, laptop sleep) recover on the loader's next retry. - Reconnection — loaders retry with backoff, quietly, for as long as it takes. You can start the dev server after opening the page.
Transports: GM (default) vs fetch
The loader polls the dev server one of two ways. The default is GM.xmlHttpRequest; fetch() is an opt-in for a snappier feel.
GM.xmlHttpRequest — the default (useGmFetch: true).
- Works on every site, including ones with a strict
Content-Security-Policy(it bypasses page CSP) — and there's no browser permission prompt. - The loader adds
@grant GM.xmlHttpRequest+@connect localhost/127.0.0.1automatically (so the script runs sandboxed during dev). - Trade-off: the userscript manager funnels these requests through one shared background connection per host (measured on Tampermonkey + Chromium — effective concurrency 1). While one tab holds a poll, other tabs' loads wait, which is why
longPollTimeoutMsis kept short (~1s). Edit-triggered reloads are unaffected — a save resolves the waiting poll instantly.
fetch() — opt-in, via --fetch (whole server) or useGmFetch: false (one script).
- A little snappier: it uses the browser's normal connection pool, so there's no cross-tab serialization — fresh tabs and refreshes load immediately even while other tabs are polling. It also runs the script in page context (closest to production).
- But it may not work everywhere:
- The browser can show a local-network permission prompt the first time — e.g. "<site> wants to access other apps and services on this device". You have to click Allow; once allowed it works instantly. (Until allowed, requests don't go through.)
- Sites with a strict
connect-srcCSP block it outright.
Rule of thumb: keep the default (GM) — it always works. Switch to --fetch when you want the snappier feel and your target site allows it.
Writing hot-reload-friendly userscripts
On every rebuild your script is re-executed on a live page. Treat startup like a fresh page load that may have leftovers from the previous run.
Option A — the hot context (recommended)
The loader exposes a context with dispose callbacks and a data store that survives reloads:
// read synchronously at the top of your script
const hot = window.__USERSCRIPT_HOT_RELOAD_CURRENT__ ?? null;
function main() {
const panel = document.createElement('div');
document.body.appendChild(panel);
const interval = setInterval(tick, 1000);
const observer = new MutationObserver(onMutate);
observer.observe(document.body, { childList: true, subtree: true });
// called right before the next version executes
hot?.onDispose(() => {
panel.remove();
clearInterval(interval);
observer.disconnect();
});
// state that survives reloads (like Vite's import.meta.hot.data)
if (hot) hot.data.bootCount = (hot.data.bootCount ?? 0) + 1;
}
main();In production (no loader) hot is simply null — no code changes needed.
Option B — idempotent startup
Remove your own leftovers before creating anything:
document.getElementById('my-panel-host')?.remove();Both options compose; A is cleaner for intervals/observers/listeners.
Recipes
Vite + vite-plugin-monkey (Svelte/React/Vue/vanilla)
# userscript.config.yml
buildCommand: vite build --watchnpx userscript-hot-reload # one terminal, donevite-plugin-monkey writes a complete metadata block, so loaders inherit your @match/@grant/@connect with zero extra config.
webpack / Rollup / esbuild
buildCommand: webpack --watch # or: rollup -c --watch
watchDir: ./buildMake sure your output filename ends in .user.js and includes a ==UserScript== header (or declare matches in the config).
No build tool (hand-written script)
userscript-hot-reload --dir . # serve foo.user.js straight from the project rootEvery save of the file itself triggers a reload. |
Programmatic API
import { resolveConfig, createDevServer, parseMetadata, generateLoader } from 'userscript-hot-reload';
const config = await resolveConfig({ cwd: process.cwd() });
const server = createDevServer(config);
await server.start();
console.log(`Dashboard: ${server.serverUrl}/`);
// later: await server.stop();HTTP endpoints
| Endpoint | Description |
| ------------------------------------------| ---------------------------------------------------------------------------|
| GET / | Dashboard (auto-refreshes on rebuild) |
| GET /<file>.user.js | Built script (X-Userscript-Version header = content hash) |
| GET /<file>.user.js?on-update&v=<hash> | Long-poll: responds when content differs from <hash> (204 on timeout) |
| GET /__loader__/<base>.hot.user.js | Generated loader (one-click install) |
| GET /__status__ | JSON status (scripts, versions, connected clients) |
| POST /__shutdown__ | Graceful shutdown (used for instance takeover) |
Security notes
- The server binds to
127.0.0.1by default and serves only*.user.jsfiles from the watch directory. Access-Control-Allow-Origin: *andAccess-Control-Allow-Private-Network: trueare required so pages (https) can poll localhost — only enable--host 0.0.0.0on networks you trust.- This is a development tool; don't run it on production machines.
License
MIT
