npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

userscript-hot-reload

v0.3.0

Published

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

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.

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