A developer-grade userscript manager with real-time, two-way sync between .user.js files on disk and your browser.
Edit a script in the dashboard → the file on disk is overwritten instantly.
Edit a file in your editor (VS Code, Vim, etc.) → the dashboard and every
matching page pick up the change instantly. Drop your Tampermonkey scripts into
scripts/ and they just work.
It's a tiny, self-hosted alternative to Tampermonkey aimed at developers who keep their userscripts as real files in a git repo / folder and want to edit them with their own tools:
- ⚡ Live two-way sync over a local WebSocket — UI ⇄ disk, no save buttons to babysit.
- 🧩 Tampermonkey-compatible
GM_*/GM.*API, including CORS-freeGM_xmlhttpRequest. - 📦 Import/Export in Tampermonkey's exact
.zipbackup format. - 🕑 Per-script version history — every save is snapshotted to disk; browse, preview and restore past versions, or clear history (per script or globally).
- 🎨 Built-in dashboard with a syntax-highlighting editor, a settings drawer, per-tab toolbar popup, and enable/disable toggles.
- 🔒 100% local. Nothing leaves your machine. No CDN, no telemetry, no account.
Heads-up: this is a personal developer tool, not a Web Store extension. It runs unpacked in developer mode and grants itself broad host access on purpose (so
GM_xmlhttpRequestcan reach any local/remote API). Use it for your own scripts on your own machine.
| Browser | Chrome, Edge, or Brave v120+ (needs the chrome.userScripts API) |
| PHP | 8.1+ on PATH (php -v) |
| Composer | Latest (getcomposer.org) |
PHP + Composer are the only dependencies. The single library used (cboden/ratchet,
a WebSocket server) is fetched by Composer; everything else is vanilla.
Linux / macOS
git clone https://github.com/saymonn37/SayScript.git
cd SayScript
./start.shWindows
git clone https://github.com/saymonn37/SayScript.git
cd SayScript
start.batOr just double-click
start.batin Explorer.
The launcher checks PHP/Composer, runs composer install on first run, and starts
the server on ws://localhost:8165. You should see:
================================================
SayScript server running
WebSocket : ws://localhost:8165
Scripts : .../sayscript/scripts
================================================
Prefer to run it manually?
cd server
composer install
php server.php # defaults: port 8165, ../scripts, poll 1.0s
php server.php --port=8166 --dir=/path/to/scripts --interval=0.5- Open
chrome://extensions. - Turn on Developer mode (top-right).
- Load unpacked → select the
extension/folder. - Open the extension's Details page and enable “Allow user scripts”
(Chrome shows this toggle for any extension using the
userScriptsAPI — it's inert without it). - Click the SayScript toolbar icon → the Dashboard opens.
The toolbar badge shows how many enabled scripts run on the current tab, and turns green when the background worker is connected to the server.
Drop any .user.js files into the scripts/ folder (or import a Tampermonkey
backup — see below). They appear in the dashboard instantly and inject on matching
pages on the next page load.
- Left panel — your scripts, alphabetical, with their
@icon. Click the green dot to enable/disable a script (greys out + strikethrough when off). Filter with the search box. - + New — opens the editor with a template immediately (no name prompt); the
filename is derived from
@nameon first save and de-duplicated automatically. - Editor — syntax-highlighted JS. Ctrl/Cmd + S writes to disk (the Save button briefly flips to ✓ Saved). Esc or ✕ Close leaves the editor. The Enabled / Disabled button (in the header action row) mirrors the list dot.
- Live reload — external file changes reload the open script (your unsaved edits are never clobbered — you get a warning instead).
- ⟲ History (editor header) — every save is snapshotted under
scripts/.history/<file>/. Browse versions, preview their code, Restore one into the editor (review, then Ctrl+S), or Clear history for that script. - ⚙️ Settings (top-left, next to the connection indicator) — a slide-out
drawer holding the Default author, a History limit (max versions kept
per script, default 20), Import / Export, and a guarded Clear all
history (type
confirmto wipe every script's history). - Toolbar popup — lists scripts running on the current tab with on/off toggles. Click a script name to jump straight into editing it in the dashboard, or use Open Dashboard.
- Import / Export (Settings drawer) — Tampermonkey-compatible
.zipbackups: per script a<Name>.user.js+.options.json+.storage.json, plus aTampermonkey.global.jsonand aSayScript.settings.json(your default author). Import restores GM storage values, enabled state, and — if present — the default author (older backups without it import fine).
Injected before every script (extension/gm-polyfill.js):
| API | Notes |
|---|---|
GM_xmlhttpRequest, GM.xmlHttpRequest |
Routed through the background fetch() → bypasses page CORS (the extension holds <all_urls>). Supports onload/onerror/onloadend, responseType text/json/arraybuffer/blob, headers, timeout, abort. |
GM_setValue / GM_getValue / GM_deleteValue / GM_listValues |
Per-script chrome.storage.local. GM_getValue is synchronous (values embedded into the injection preamble); GM.* promise variants also provided. |
GM_log, unsafeWindow |
console.log prefixed with the script name; unsafeWindow → window. |
GM_addStyle, GM_openInTab, GM_setClipboard, GM_registerMenuCommand, GM_notification, GM_info / GM.info |
Practical equivalents. |
sayscript/
├── start.sh / start.bat ← one-command setup + launch (Linux·macOS / Windows)
├── scripts/ ← your *.user.js files (kept out of git; example included)
├── server/ ← local WebSocket + file-watch backend (PHP / Ratchet)
│ ├── server.php ← thin bootstrap (CLI parse · wire · run loop)
│ ├── src/
│ │ ├── MetadataParser.php ← parses the ==UserScript== block
│ │ ├── ScriptRepository.php ← *.user.js file IO + path-traversal guard
│ │ ├── HistoryStore.php ← per-script version history + pruning
│ │ └── ScriptSync.php ← the Ratchet WebSocket component
│ └── composer.json
└── extension/ ← the Chromium MV3 extension (load this unpacked)
├── manifest.json
├── gm-polyfill.js ← GM_* / GM.* layer (injected as text — stays one file)
├── background/ ← service worker (ES modules)
│ ├── index.js ← entry: message routers · control API · boot
│ ├── store.js ← state + chrome.storage persistence + GM values
│ ├── matching.js ← URL → applicable scripts
│ ├── registration.js ← chrome.userScripts (un)registration
│ ├── badge.js ← per-tab badge counts
│ ├── gm-bridge.js ← GM_* messages + CORS-free fetch
│ └── ws-client.js ← resilient WebSocket to the server
├── options.html , options.css
├── options/ ← dashboard scripts (classic, shared scope)
│ ├── dashboard.js ← editor · list · settings · history · import/export
│ ├── highlight.js ← self-contained JS syntax highlighter
│ ├── icons.js ← @icon cache (data: URLs in chrome.storage)
│ └── zip.js ← dependency-free ZIP reader/writer
├── popup.* ← toolbar popup
└── assets/ , icons/ ← logo & icon
- Injection uses the
chrome.userScriptsAPI, noteval— it runs scripts in a CSP-exempt world, honours@run-at/match filtering, and (viaconfigureWorld({messaging:true})) lets the GM polyfill talk to the background worker. That's what powers the CORS-freeGM_xmlhttpRequest. Each script is wrapped in its own IIFE so they never collide. - The editor is self-contained — MV3 forbids remote scripts on extension pages, so instead of a CDN CodeMirror there's a small offline JS tokenizer.
- Self-healing — the worker reconnects with backoff, keeps script state in
chrome.storage.local, and debounces bulk changes (importing hundreds of scripts triggers a single re-registration). - No echo loops — a UI save updates the server's snapshot before the watcher fires, so it broadcasts only to other clients.
Message protocol (JSON over WS). Client → server: fetch_all_scripts,
update_script {filename, code}, create_script {filename, code?},
delete_script {filename}, ping. Server → client: all_scripts,
script_changed, script_deleted, update_ack, delete_ack, pong, error.
| Symptom | Fix |
|---|---|
| Badge grey / dashboard says offline | The server isn't running, or something else holds port 8165. Restart ./start.sh (or start.bat), or pass --port=8166. |
| Scripts don't inject | Enable “Allow user scripts” on the extension's Details page. Check the service-worker console (chrome://extensions → SayScript → Inspect views: service worker) for registered N/M script(s). |
GM_xmlhttpRequest fails |
Use the GM API for cross-origin calls — a plain fetch() inside your script is still subject to page CORS. The worker logs the real error in the service-worker console. |
Changed server.php but nothing changed |
PHP doesn't hot-reload — stop the server (Ctrl+C) and start it again. |
Windows: php not found |
Install PHP 8.1+ and add its folder to PATH; reopen the terminal. |
The scripts/ folder is git-ignored (scripts/.gitignore) except for
example.user.js. Your personal userscripts never get committed or published —
clone the repo, drop your own scripts in, and they stay on your machine.
To publish your own fork: git init, commit, and push. Replace
extension/assets/logo.png (and extension/icons/icon128.png) to rebrand.
MIT © 2026 Szymon Obrzut