JSPM

userscript-hot-reload

0.3.0
    • ESM via JSPM
    • ES Module Entrypoint
    • Export Map
    • Keywords
    • License
    • Repository URL
    • TypeScript Types
    • README
    • Created
    • Published
    • Downloads 11
    • Score
      100M100P100Q88756F
    • License MIT

    A small dev server that hot-reloads userscripts in place — works with any build tool (Tampermonkey / Violentmonkey)

    Package Exports

    • userscript-hot-reload

    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-reload

    It 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 global

    Add 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 204 and re-issued. The hold is kept short on purpose: with the default GM.xmlHttpRequest transport, 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.1 automatically (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 longPollTimeoutMs is 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-src CSP 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.

    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 --watch
    npx userscript-hot-reload   # one terminal, done

    vite-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: ./build

    Make 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 root
    Every 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.1 by default and serves only *.user.js files from the watch directory.
    • Access-Control-Allow-Origin: * and Access-Control-Allow-Private-Network: true are required so pages (https) can poll localhost — only enable --host 0.0.0.0 on networks you trust.
    • This is a development tool; don't run it on production machines.

    License

    MIT