VibeX
A client mod for X (Twitter) web, in the spirit of Vencord. It ships as a userscript and as a browser extension for Chrome and Firefox, all built from the same source.
Build & install
npm install
npm run build # all targets; extensions minified (CSS included), userscript not
npm run build -- --target=chrome # or userscript / firefox, comma-separated
npm run watch # unminified rebuilds while developing
npm run typecheck
The extension builds minify JavaScript with esbuild and every css template with Lightning CSS. The userscript is left unminified, since Greasy Fork rejects minified code.
| Target | Output | Install |
|---|---|---|
| Userscript | dist/vibex.user.js |
Tampermonkey or Violentmonkey. It must run at document-start (the header asks for this) so the webpack hook goes in before X's bundles execute. |
| Chrome / Chromium (111+) | dist/chrome/, dist/vibex-chrome.zip |
chrome://extensions → Developer mode → Load unpacked → dist/chrome. The zip is for the Chrome Web Store. |
| Firefox (128+) | dist/firefox/, dist/vibex-firefox.zip |
about:debugging#/runtime/this-firefox → Load Temporary Add-on → dist/firefox/manifest.json (until restart). Permanent installs need the extension signed by Mozilla — see Signing for Firefox. |
Then open Settings → VibeX on x.com (or click the extension's toolbar button). Install one target only: if two load, the second one notices and stays off.
Signing for Firefox
Release Firefox only installs extensions signed by Mozilla. npm run sign:firefox rebuilds the Firefox target and uploads it to addons.mozilla.org (AMO) through web-ext (scripts/sign-firefox.mjs).
First create an API key + secret at https://addons.mozilla.org/developers/addon/api/key/ and provide them as environment variables, or in a .env file in the project root (gitignored; real environment variables take precedence over it):
# .env
AMO_JWT_ISSUER=user:xxxxxx:xxx
AMO_JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
npm run sign:firefox # unlisted (default)
npm run sign:firefox -- --channel listed # public AMO store
--channel unlisted(default) — self-distribution. AMO signs the add-on without listing it and the signed file is downloaded todist/vibex-firefox.xpi. Host it yourself (e.g. on the nightly release); users install the.xpidirectly and it stays installed.--channel listed— submit to the public AMO store for human review. Mozilla hosts and distributes the add-on, so no signed file is returned here. Because the build is bundled and minified, the script also uploads the source for the reviewers: an archive of the committed source (git archive HEAD→dist/vibex-source.zip). Commit and ideally tag before submitting so the source matches the signed build — the script warns if the working tree is dirty. Override the archive with--source <zip>, or skip it with--no-sourceto attach it through the AMO web UI instead. A license and category slugs are sent too — thepackage.jsonlicense as an SPDX slug, and a default category set (social-communication,appearance). Override with--license <slug>and--category <slug>(repeatable, 1-3 total), or supply a full--amo-metadata <json>file. The rest of the listing (name, description, screenshots) is completed on AMO.
The add-on id is browser_specific_settings.gecko.id (vibex@jakeayy.ch, set in build.mjs); it must stay stable and be unique on AMO for a listed add-on. Signing is a manual step; the nightly workflow still ships the unsigned dist/vibex-firefox.zip for temporary install.
Publishing for Chrome
npm run publish:chrome rebuilds the Chrome target and uploads dist/vibex-chrome.zip to the Chrome Web Store through its API v2 (scripts/publish-chrome.mjs).
npm run publish:chrome # upload the new version as a draft
npm run publish:chrome -- --publish # upload and submit it for review
Without --publish it only uploads a draft you can inspect in the dashboard; with --publish it also submits for review (it goes live, with the item's current visibility, once Chrome approves it). The manifest version must be higher than the store's current one.
One-time setup:
- Create the extension once in the Developer Dashboard (by uploading a zip there) and complete its Store listing and Privacy tabs — the API can't create a brand-new item or fill the listing.
- In the Google Cloud Console, create a project, enable the Chrome Web Store API, configure the OAuth consent screen, and create an OAuth client of type Web application (add
https://developers.google.com/oauthplaygroundas a redirect URI). - Get a refresh token for the scope
https://www.googleapis.com/auth/chromewebstore(e.g. via the OAuth Playground, using your own client id/secret).
Then provide the credentials as environment variables or in .env (gitignored):
# .env
CHROME_CLIENT_ID=xxxxxxxxxxxx.apps.googleusercontent.com
CHROME_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
CHROME_REFRESH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx
CHROME_PUBLISHER_ID=xxxxxxxxxxxxxxxxxxxxxxxx # Dashboard → Publisher → Settings
CHROME_EXTENSION_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # the item's id from its URL
Publishing is a manual step; the nightly workflow still ships the unsigned dist/vibex-chrome.zip for load-unpacked install.
Releasing both at once
npm run release bumps the version, builds, and ships to both stores via the scripts above (scripts/release.mjs). Defaults are conservative — Firefox unlisted, Chrome draft — and you escalate explicitly:
npm run release # patch bump; FF unlisted + Chrome draft
npm run release -- --bump minor # minor bump instead
npm run release -- --firefox listed --chrome submit --push # full public release + push
npm run release -- --dry-run # print the steps without running them
npm version makes the bump a commit and tag (so it needs a clean working tree); --push pushes the commit and tag after a fully successful release. Other flags: --version <x.y.z>, --no-bump, --no-firefox, --no-chrome. If one store fails the other still runs; since the version is already bumped, retry the failed one with its own script (no second bump).
Nightly builds
.github/workflows/nightly.yml (GitHub) and .forgejo/workflows/nightly.yml (Forgejo) build every target on each push to main that touches the build (sources, assets, build config, dependencies — not docs) and on demand. Each then replaces the nightly prerelease: the old release and its tag are deleted, and a new one is created on the built commit with vibex.user.js, vibex-chrome.zip and vibex-firefox.zip attached (GitHub via gh, Forgejo via scripts/nightly-release.mjs). The userscript's @updateURL/@downloadURL point at vibex.user.js on the GitHub nightly. Nightlies append the run number to the version (0.1.2 → 0.1.2.57, via VIBEX_BUILD) so userscript managers pick each one up as an update; the next release (0.1.3) still sorts above them. The Forgejo workflow uses the automatic token Forgejo gives every run (secrets.FORGEJO_TOKEN). If that can't manage releases on your Forgejo instance, add a repository secret NIGHTLY_TOKEN with write access to the repository.
What the extension adds
The page code is the same in every target; the extension builds add what a userscript can't do:
- Guaranteed early start. VibeX is a
document_startcontent script in the page's own JS world ("world": "MAIN"), which the browser runs before any page script. A userscript manager usually manages this too, but not always, and a late start leaves every webpack patch unapplied. - Network-level blocking. Plugins declare
declarativeNetRequestrulesets (src/extension/rules/) that follow their settings: SponsorBegone's tracking block (telemetry) and NoGrok's request block (grok). The in-page interceptors still answer X's own calls; the rulesets also catch what they can't see (workers, the service worker, pixels). Rules apply only to requests X makes, so grok.com in its own tab is untouched. Enabled rulesets persist, so they apply from X's first request on the next visit. - Toolbar button that opens VibeX's settings.
The page (no extension APIs) talks to the extension through src/extension/bridge.ts, an isolated-world content script, which forwards to the background script (background.ts; a service worker in Chrome, an event page in Firefox). The About page shows which target is running.
Layout
src/
index.ts bootstrap: register plugins → hook webpack → start plugins
api.ts window.VibeX
webpack/
index.ts chunk hook, source patcher, module finders
react/ JSX runtime + hooks forwarding to X's own React
common.ts X's router and settings UI components (SettingsScreen, Pivot, BooleanItem, …)
plugins/
types.ts definePlugin + plugin/setting types
manager.ts enable/disable, settings, lifecycle
hooks.ts React hooks (useSettingsVersion)
index.ts list of built-in plugins (add yours here)
settings/ the VibeX settings section (itself a plugin)
cleanSidebar.tsx hide sidebar / More menu items (patches + React settings UI)
sponsorBegone.ts remove ads, block telemetry (patch + network interceptor)
oldTwitterBranding.ts bird logo, classic blue, Tweet/Retweet wording (patches)
noGrok.ts remove Grok UI, block Grok edits of your media by default
experiments.tsx browse / override feature switches
noUpsells.ts hide Premium upsells via switches
mediaDownloader.ts Share-menu download item
featureSwitchesAPI.ts core switch hook (required)
consoleShortcuts.ts webpack helpers in the console
core/ shared hooks plugins build on (feature switch resolvers, extension bridge)
extension/ extension-only code: bridge content script, background script, network rulesets
utils/ style helpers, router, storage, logger, network interceptors
How it hooks X
- Webpack. X is a webpack 5 app whose chunks are pushed onto
window.webpackChunk_twitter_responsive_web. VibeX creates that array first and wrapspush, so every module factory goes through the patcher before webpack installs it. It also captures webpack'srequire(VibeX.webpack.wreq) to search loaded modules. - CSP. x.com's CSP blocks
evalandnew Function, and reports violations. Patched modules are compiled through an inline<script>that carries the page's own CSP nonce. - React. VibeX doesn't bundle React.
.tsxfiles compile against@vibex/react/jsx-runtime, a shim that forwards to X's own React 19 runtime, found through webpack. Import hooks from@vibex/react, never fromreact: a second React copy would break hooks. - Settings pages. The Settings plugin patches X's settings route table to add
/settings/vibex,/about,/pluginsand/plugins/:nameas real routes. X's router renders them in the detail column, and they appear in X's settings search. The pages are built from X's ownSettingsScreen,PivotandBooleanItemcomponents, with plain-markup fallbacks if X renames one.
Built-in plugins
| Plugin | What it does | How |
|---|---|---|
| Settings | The VibeX settings section | Patches X's settings nav and route table |
| CleanSidebar | Hide any sidebar item, More menu entries, or swap More for a Settings tab | Patches the nav tab array and the More menu item builder |
| SponsorBegone | Removes promoted posts/accounts/trends and other ads, blocks telemetry | Rewrites includePromotedContent in GraphQL requests, patches X's timeline entry pipeline to drop anything with promotedMetadata, answers X's telemetry endpoints locally |
| OldTwitterBranding | Bird logo and favicon, classic #1DA1F2 blue, blue Tweet buttons, Tweet / Retweet / Twitter wording |
Patches the logo icon, colour palettes, button styles and X's English string catalogs |
| NoGrok | Removes Grok and Grok Imagine (sidebar tab, post buttons, floating button, edit-image and profile "Edit with Imagine" buttons, prompts) blocks Grok edits of your media by default, and hides posts by or @mentioning @grok, and blocks X's background Grok requests | Answers false for X's Grok feature switches, hides the floating drawer, defaults the media "Block modifications by Grok" option to on, and filters timeline batches using posts classified from API responses (an all-Grok batch is left as-is so X doesn't keep fetching) |
| Experiments | Browse all of X's feature switches (server default vs. your account) and override them | Overrides through the FeatureSwitchesAPI resolver chain; no descriptions, since X publishes none and community lists only guess from names |
| NoUpsells | Hides Premium / Verified upsells, promo banners, app-load paywalls and "Boost" nudges | Turns off only switches that display upsells; paid features (subscriptions_feature_*, long video, …) are untouched |
| MediaDownloader | "Download photo / video / GIF" in every post's Share menu, original quality | Adds an item to X's share-menu item table; respects creators who disabled downloads (setting) |
| BetterBlockedWords | Hides or blocks the author of posts containing your X muted words (one action for all) and your own phrases or regexes (an action per phrase); counts posts hidden and users blocked | Filters X's timeline pipeline using posts from API responses and X's own post store (the home timeline restored from X's saved session is rechecked and pruned with X's REMOVE_TWEETS action). Reads muted words through X's API client; blocks through X's own Users block action, one every 3 s, max 50 per page load, never accounts you follow |
| TwitterFixes | Core: fixes for X's own code. Smoother scrolling, and profiles that can't break the page | Turns on two optimizations X wrote but ships disabled: timeline_scroll_pointer_events_optimization (no hover style recalcs while scrolling) and responsive_web_offscreen_video_scroller_removal_enabled (off-screen videos stop measuring their position on every scroll). Patches the profile header's location and the professional-profile spotlight lines (address, website, contact) to collapse line breaks and cap the length, since X's API accepts crafted text such as a 500,000-line "URL". Each fix can be turned off in its settings |
| FeatureSwitchesAPI | Core: one patch on X's FeatureSwitches class, shared by all switch-overriding plugins | core/featureSwitches.ts → addSwitchResolver() |
| ConsoleShortcuts | vx helpers in DevTools |
— |
Writing a plugin
import { css } from "../utils/css";
import { definePlugin } from "./types";
export default definePlugin({
name: "MyPlugin",
description: "What it does.",
authors: ["you"],
settings: {
loud: { type: "boolean", description: "Be loud", default: false },
},
css: css`/* applied while enabled */`,
start({ settings, logger, addStyle, onStop }) {
logger.info("started, loud =", settings.loud);
const id = setInterval(() => {}, 1000);
onStop(() => clearInterval(id));
},
onRouteChange(path) {},
});
Then add it to src/plugins/index.ts.
- Write stylesheets as
csstagged templates (utils/css), forcss:andaddStyle()alike. The extension builds minify each one with Lightning CSS and drop the tag; the userscript keeps it as-is. Interpolations work, except inside quoted CSS strings: such a template is left unminified. startandstoprun live when the user toggles the plugin. Changing a setting restarts the plugin unless that setting hasrestartNeeded: true, which shows a "Reload needed" banner instead.startEarly: truestarts the plugin at document-start instead of DOMContentLoaded, before X's bundles run (no DOM yet). SponsorBegone uses it so X's very first requests are already intercepted.networkRules: { <rulesetId>: settings => boolean }turns on a ruleset fromsrc/extension/rules/<id>.jsonin extension builds (the userscript ignores it); keep the in-page interceptor too, so the userscript gets the same blocking.addInterceptor()fromutils/networkhooks every XHR,fetchandsendBeaconrequest: rewrite its URL or body, or block it (it then completes locally as an empty 200).addResponseObserver()fromutils/networksees every completed XHR/fetchresponse read-only, before X's own handlers;res.json()parses lazily, so observers that filter byres.urlfirst cost nothing.settingsPaneladds your own React component to the plugin's settings page, above its settings (BetterBlockedWords shows its counters there).- Setting types are
boolean,string,number,selectandcustom(your own React component that receives{ value, set }). The settings page is generated from them. A setting can also takesection(groups it under a heading) anddisabled(settings)(greys it out, e.g. under a parent toggle).
Webpack patches
patches: [{
find: 'testID:"helpCenterLink"', // string that identifies the module
replacement: {
match: /link:"\/settings\/about"/, // string or RegExp (use /g for every hit)
replace: '$&,onClick:$self.handleClick', // $self = this plugin object
},
}],
handleClick() { /* ... */ },
Patches are applied only when the page loads, so toggling a plugin that has patches shows a "Reload needed" banner. Patched code can still read settings live: CleanSidebar's patches route X's data through $self functions that check the current settings, and its React wrapper re-renders on change. Patches that never matched are listed on the About page. Use [\w$]+ rather than hard-coding minified variable names, because they change on every X deploy.
Console helpers
Enable ConsoleShortcuts to get a vx object in DevTools with vx.findByProps(...), vx.findByCode(...), vx.searchModules("some string") and vx.source(id).
