Tampermonkey / tampermonkey

Tampermonkey is the most popular userscript manager, with over 10 million users. It's available for Chrome, Microsoft Edge, Safari, Opera Next, and Firefox.
GNU General Public License v3.0
4.17k stars 416 forks source link

Allow redefinition of variables such as `define`, `module` & `exports` #1558

Open DerekZiemba opened 2 years ago

DerekZiemba commented 2 years ago

Issue

Tampermonkey wraps userscripts in the following block:

window["__p__5239734.2438736325"] = function () {
  ((context, powers, fapply) => {
    with (context) {
      (module => {
        "use strict";
        try {
          fapply(module, context, [
            undefined, undefined, powers.CDATA, powers.uneval, powers.define, 
            powers.module, powers.exports, context, context, powers.unsafeWindow, 
            powers.cloneInto, powers.exportFunction, powers.createObjectIn, 
            powers.GM, powers.GM_info, powers.GM_addElement, powers.GM_getResourceText, 
            powers.GM_addStyle, powers.GM_log, powers.GM_listValues, powers.GM_getValue, 
            powers.GM_setValue, powers.GM_deleteValue, powers.GM_addValueChangeListener]);
        } catch (e) {
          if (e.message && e.stack) {
            console.error("ERROR: Execution of script 'TEST' failed! " + e.message);
            console.log(e.stack);
          } else {
            console.error(e);
          }
        }
      }
      )(async function tms_00d14b36_761e_4d2c_9d24_90f582a28414$(
            context, fapply, CDATA, uneval, define, 
            module, exports, window, globalThis, unsafeWindow, 
            cloneInto, exportFunction, createObjectIn, 
            GM, GM_info, GM_addElement, GM_getResourceText, 
            GM_addStyle, GM_log, GM_listValues, GM_getValue, 
            GM_setValue, GM_deleteValue, GM_addValueChangeListener) {
       // my user script here
        // ==UserScript==
        // @name         TEST
        // @noframes
        // @nocompat
        // @grant GM_listValues
        // @grant GM_getValue
        // @grant GM_setValue
        // @grant GM_deleteValue
        // @grant GM_addValueChangeListener
        // @grant unsafeWindow
        // @grant module        /**Shot in the dark to try and get an actual value... */
        // @grant exports        /**Shot in the dark to try and get an actual value... */
        // @grant node            /**Shot in the dark to try and get an actual value... */
        // @grant es6               /**Shot in the dark to try and get an actual value... */
        // @grant commonjs   /**Shot in the dark to try and get an actual value... */
        // ==/UserScript==
        'use strict';
        debugger;
      });
    }
  }
  )(this.context, this.powers, this.fapply);
  //# sourceURL=chrome-extension://iikmkjmpaadaobahmlepeloendndfphd/userscript.html?name=TEST.user.js&id=00d14b36-761e-4d2c-9d24-90f582a28414
};

Problem

It passes variables such as context, define, exports, module, fapply all with undefined values. In particular, I need exports & module to either have actual values supplied by tampermonkey, or for the variables not to be passed at all.

  1. I can't find any documentation as to how to get TamperMonkey to pass actual values. There must be a reason for them, right?
  2. I can't figure out a way to force them to be omitted.

If they were omitted, I could declare them as properties on this and have all references get properly directed to the correct object. Otherwise, exports = 'something' will write to the variable unless I explicitly do module.exports = 'something'. If it was a property on this, I could have the exports setter automatically merge all exports.

What I'm trying to do

Basically trying to hack around vscode so I can get better intellisense. Since vscode doesn't recognize // @require file:///A:/Dropbox/Scripts/.js/ZZ/core/GM.js I can put my own implementations off require, import, module, exports in there then just ensure it's the first @require'ed script.
The additional scripts that are loaded after that can then use typical node.js module syntax and vscode will be none the wiser that it's all fake, and will give me proper intellisense and type information..

Expected Behavior

Not to define exports & module if Tampermonkey isn't going to provide something by them.

Actual Behavior

Tampermonkey passes undefined parameters exports & module to the wrapped script .

Specifications

Script

In Tampermonkey, the script is pretty much just the header

// ==UserScript==
// @name         RESEnhancementSuiteEnhnacer2
// @description  Enhances RedditEnhancementSuite
// @match          *://*.reddit.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=reddit.com
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_deleteValue
// @grant           GM_addValueChangeListener
// @grant           unsafeWindow
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/core/GM.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/DeferredAction.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/Debouncer.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/reddit.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/topSublinksBar.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/SideBars.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/ContinueThisThread.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/MenuBar.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/Filters.js
// @require                 file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/PostManager.js
// @resource        STYLE   file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/reddit.css
// @resource        HTML    file:///A:/Dropbox/Scripts/.js/ZZ/websites/reddit/reddit.html
// ==/UserScript==

The // @require file:///A:/Dropbox/Scripts/.js/ZZ/core/GM.js script would be something like this, if those undefined exports & module parameters didn't throw a wrench in my plan.

const __module = {
  __exports: {},
  get exports() { return this.__exports; },
  set exports(value) {
    if (value.name === 'ZZ') { // The name of my module I want to export to the page
      Object.defineProperties(value, Object.getOwnPropertyDescriptors(exports))
      Object.defineProperty(unsafeWindow, 'ZZ', { value: this.exports });
    } else { // everything else just add it to the ZZ module
      const existing = this.exports[value.name];
      if (existing instanceof Promise) { existing.resolve(value); }
      this.exports[value.name] = value;
    }
  }
};
Object.defineProperties(this, {
  module: {
    get() { return __module; },
    set(value) {
      for(let key in value) {
        if (key in __module) { // basic merge 
          Object.defineProperties(__module[key], Object.getOwnPropertyDescriptors(value[key]));
        } else {
          Object.defineProperty(__module, key, Object.getOwnPropertyDescriptor(value, key));
        }
      }
    }
  },
  exports: {
    get() { return this.module.exports; },
    set(value) { return this.module.exports = value; },
  },
  require: {
    value: (path)=>{
      const name = /\/?([a-zA-Z0-9_-.]+?)(?:\.js)?$/.exec(path)[1]
      return __module.exports[name];
    }
  },
  import: {
    value: async (path)=>{
      const name = /\/?([a-zA-Z0-9_-.]+?)(?:\.js)?$/.exec(path)[1]
      return __module.exports[name] ??= deferredPromise();
    }
  }
});
const deferredPromise = ()=>{
  let resolve, reject;
  /**@type {any} */
  const prom = new Promise((res, rej) => { resolve = res; reject = rej; });
  prom.resolve = resolve;
  prom.reject = reject;

  return prom;
};
derjanb commented 2 years ago

Why does does Tampermonkey pass undefined variables such as module & exports

These variables are hidden from @require'd scripts so they don't mistakenly use a page implementation.

I'll think about a better way to achieve this after the next stable release.

anonghuser commented 1 year ago

just a note, i dont believe exports = ... is valid commonjs, so you dont need such magical setter/getter if you want to mimic it. in commonjs you only assign individual fields in exports, not the whole object. whole object assignment is only possible with module.exports. And module itself is also never re-assigned, so its setter-getter magic is pointless too.

DerekZiemba commented 1 year ago

just a note, i dont believe exports = ... is valid commonjs, so you dont need such magical setter/getter if you want to mimic it. in commonjs you only assign individual fields in exports, not the whole object. whole object assignment is only possible with module.exports. And module itself is also never re-assigned, so its setter-getter magic is pointless too.

I only realized this after I got eslint working properly in vscode. However, it still works in the browser. Create a Proxy and define an exports variable on the global object.

Because of the linter and other complications, ultimately I did end up using module.exports. Having module defined is still problematic though. For example it's not straightforward to share modules between GM scripts because module is always passed and set as undefined. The reason I want to share modules is so I can share data across origins.

But I've been basically doing the following:

/* rome-ignore */ /* @ts-ignore */ /* eslint-disable-next-line */
module ??= unsafeWindow.__MyGMModules ?? (()=>{ 
  const modules = {  };
  const rgxModulePath = /((?:\.\.?\/)+)?(.+\/?)?([a-zA-Z0-9_.-]+?)(\.(?:m?js|ts|d\.ts))?$/;
  const parseModulePath = (requirePath) => { /* Get simplified name using rgxModulePath */ }
  const myRequire = (path) => { return modules[parseModulePath(path)]; /* (stripped logic for brevity) */ } 
  const createExportProxy = (path) => {
    const name = parseModulePath(path);
    return modules[name] ??= new Proxy({ name }, {
      ownKeys: Object.keys,
      has: Object.hasOwn,
      construct(target, args, newTarget) { /* (stripped logic for brevity) */ },
      getPrototypeOf(target) { /* (stripped logic for brevity) */ },
      defineProperty(target, name, descriptor) { /* (stripped logic for brevity) */ },
      getOwnPropertyDescriptor(target, name) { /* (stripped logic for brevity) */ },
      get(target, prop, receiver) { /* (stripped logic for brevity) */ },
      set(target, prop, value) { /* (stripped logic for brevity) */ },
    });
  }
  let currentName = '.';
  let currentExportsProxy = createExportProxy(currentName);
  return new Proxy(modules, {
    ownKeys: Object.keys,
    has: Object.hasOwn,
    get(target, prop, receiver) {
      switch(prop) {
        case 'require': return myRequire;
        case 'name':  return currentName;
        case 'exports': return currentExportsProxy;
        default: return target[prop] ?? modules['.'][prop];
      }
    },
    set(target, prop, value) {
      switch(prop) {
        case 'name':
          currentExportsProxy = createExportProxy(currentName = value);
          break;
        case 'exports': 
          Object.assign(currentExportsProxy, value);
          break;
      }
    },
  });
})();

/* rome-ignore lint(correctness/noShadowRestrictedNames): reason */ /* @ts-ignore */ /* eslint-disable-next-line */
const require = module.require;

/// Then later:
{
    const Resource = require('../path/is-ignored/only-uses-file-name.js');
    // create a new export proxy (module) and names it so I can require it later;
    module.name = 'name-im-calling-this-module'; 
    module.exports.someFunction = ()=>{};
}
anonghuser commented 1 year ago

It seems to me that you are inventing your own module system, a bit similar to commonjs but still not able to load unmodified commonjs modules because of the need to define module.name; it also seems very sensitive to the order of @import clauses, needing all required modules to precede the requiring modules. Instead of all that, if you're gonna have to modify your modules anyway, why don't you use standard AMD or UMD module syntax with an AMD loader which is very well suited to modules concatenated to a single file in any order? It's not hard to manually convert them (wrap in a define() function, extract dependency list and module name as arguments) but they can even be generated from commonjs by various build tools like webpack or browserify. And if you go there anyway the tools can even concatenate them for you so your userscript ends up with a single @import and no code at all. Or even better, since @import from local file really seems like a bad practice, just prepend the userscript header to the tool-generated output script.

The reason I want to share modules is so I can share data across origins

I do not understand what you mean here. No matter what module system you come up with, each frame will still have its own script realm with its own modules, there is no way around that. To share data, you will need postMessage/onmessage (for frames within the same tab) or GM.getValue/GM.setValue/GM.addValueChangeListener for different tabs, and that has nothing to do with this issue

Lastly, since I got a bit sidetracked, it is not clear to me if you still think there is an issue with tampermonkey itself that needs addressing here, or the issue can be closed.

7nik commented 1 year ago

Basically trying to hack around vscode so I can get better intellisense.

Solution 1: install TS and define the global variables you use https://code.visualstudio.com/docs/nodejs/working-with-javascript#_global-variables-and-type-checking

Solution 2: use standard import/require and then compile into a single bundle. There are plugins for bundlers to generate userscripts.