vite-plugin-monkey icon indicating copy to clipboard operation
vite-plugin-monkey copied to clipboard

[Feature] Support for building with ESM-only libraries

Open owowed opened this issue 1 year ago • 10 comments

I'd like support for building with packages that only provide ESM dist, one example is jsx-dom, which doesn't provide UMD/IIFE dist, but only providing ESM.

We can already use ESM dist in the userscript via dynamic import in async IIFE:

// ==UserScript==
// @name       esm-only packages support
// @namespace  vite-plugin-monkey
// @version    0.0.1
// @author     owowed
// @license    ISC
// @match      https://www.google.com/
// ==/UserScript==

(async function () {
	'use strict';

	var React = await import("https://cdn.jsdelivr.net/npm/[email protected]/index.min.js");

	const test = /* @__PURE__ */ React.createElement("div", null);
	document.body.appendChild(test);
	console.log(React);

})();

Maybe we can add a new build.externalDynamicImports configuration that'll automatically resolve ESM-only external modules to dynamic import:

import { defineConfig } from "vite";
import monkey from "vite-plugin-monkey";

export default defineConfig({
    plugins: [
        monkey({
            entry: "./src/main.js",
            userscript: {
                match: [
                    "https://www.google.com/"
                ]
            },
            build: {
                externalDynamicImports: { // similar to externalGlobals
                     // replace any "jsx-dom" import in the source code using `await import(url)`
                    "jsx-dom": "https://cdn.jsdelivr.net/npm/[email protected]/index.min.js"
                },
            }
        })
    ]
});

(Edited for more clarity)

owowed avatar Aug 24 '24 10:08 owowed

Since dynamic import has to follow CORS rules, here is an implementation to workaround that using GM_fetch/GM_xhr:

async function importShim<ModuleType>(url: string): Promise<ModuleType> {
    const script = await GM_fetch(url).then(res => res.text()); // recommend @trim21/gm-fetch
    const scriptBlob = new Blob([script], { type: "text/javascript" });
    const importUrl = URL.createObjectURL(scriptBlob);
    return import(importUrl);
}

Of course, this will only work if CSP allows dynamic import from blob URLs. Don't forget there is also GM_import API that can bypass this, but only available in FireMonkey (and unfortunately, no other userscript manager supports it).

owowed avatar Aug 26 '24 09:08 owowed

you can use GM_getText instead of fetch to load module text

but it is not supported that target module text import another remote relative module

lisonge avatar Aug 26 '24 10:08 lisonge

you can use GM_getText instead of fetch to load module text

I've never seen GM_getText API before, and its not in the Violentmonkey or Tampermonkey docs. Could you perhaps provide link to the documentation?

but it is not supported that target module text import another remote relative module

The importShim function doesn't import the remote module directly, it first fetches the remote module via GM_fetch, and then import that using Blob and URL.createObjectURL. Because of that, importShim imports from a blob URL that's coming from the same origin, which should run on the website without violating CORS.

owowed avatar Aug 26 '24 13:08 owowed

sorry, it should be GM_getResourceText

https://github.com/lisonge/vite-plugin-monkey/blob/d8127bd6d86966df810b93031bbac8a83f9963b6/packages/vite-plugin-monkey/src/client/index.ts#L66-L71

lisonge avatar Aug 26 '24 13:08 lisonge

but it is not supported that target module text import another remote relative module

if your target module is the following code

// it import another remote relative module
export * from './'

your importShim will not work

lisonge avatar Aug 26 '24 14:08 lisonge

You're right. The importShim function is designed to import from external module, like CDNs (jsdelivr, unpkg, cdnjs etc.) Users can still choose to use the normal import for normal relative imports.

The naming for importShim is kind of confusing since shims are meant for polyfills. Sorry, I'll refer to them as importExternal from now on 😅

owowed avatar Aug 26 '24 14:08 owowed

Actually, we can create importShim that can handle both of these cases, and support GM_getResourceText:

async function importShim(url) {
    // for importing external modules outside of its own origin or using GM_getResourceText
    if (url.startsWith("http:") || url.startsWith("https:") || url.startsWith("gm-resource:")) {
        let scriptText = url.startsWith("gm-resource:")
            ? await (GM_getResourceText || GM.getResourceText)(url.split(":")[1])
            : await GM_fetch(url).then(res => res.text());
        const scriptBlob = new Blob([scriptText], { type: "text/javascript" });
        const importUrl = URL.createObjectURL(scriptBlob);
        return import(importUrl);
    }
    return import(url);
}

GM_fetch is still useful in case the user doesn't want to manually add resource entries.

owowed avatar Aug 26 '24 23:08 owowed

but it is not supported that target module text import another remote relative module

if your target module is the following code

// it import another remote relative module export * from './' your importShim will not work

This case can be resolved?

yunsii avatar Aug 15 '25 13:08 yunsii

See this issue: https://github.com/Tampermonkey/tampermonkey/issues/1396

Based on the issue and my own tests, user scripts can use dynamic import directly. For example, this code works:

// ==UserScript==
// @name         New Userscript
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  try to take over the world!
// @author       You
// @match        https://www.google.com/
// @icon         https://www.google.com/s2/favicons?domain=github.com
// ==/UserScript==

(async function () {
  'use strict'
  console.log('start')
  // Import an ESM module directly from a CDN
  // Example using esm.sh for a library like 'uuid'
  try {
    const { v4: uuidv4 } = await import('https://esm.sh/uuid')
    console.log('Generated UUID:', uuidv4())
  } catch (error) {
    console.error('Failed to import or use ESM module:', error)
  }

  // Another example with a different CDN and library
  // const { createPicker } = await import('https://unpkg.com/picmo@latest/dist/index.js');
  // createPicker(...);
})()

However, this will be blocked if the page has a Content Security Policy (CSP). I checked that resources listed in @require are injected into userscript.html at runtime, so they are not blocked by CSP.

If you want to support dynamic import but the userscript engine itself doesn’t allow it, you would have to bundle all ESM code into the userscript at build time to avoid CSP issues. That is a build-time workaround, so it doesn't reduce the final bundle size and its benefit is limited. For example, Greasy Fork limits scripts to 2 MB — if your bundled script is larger than that, you still exceed the limit. It's better to use proper tree shaking during bundling.

In the end, true support for dynamic import should come from the userscript engine. That's my current understanding.

yunsii avatar Sep 21 '25 03:09 yunsii

@yunsii

The issue is only valuable if we consider ES importing as the browser's import api, as when you do nothing, Vite will bundle those dependencies into your dist file by default, whether they are CJS or ES modules.

While I believe that's the engine's responsibility, there should be some current workarounds for developers, as Tampermonkey is proprietary and old-fashioned in a way (for DX). Like TM doesn't support dev servers, that's why Vite exists. ;)

VLTHellolin avatar Oct 05 '25 06:10 VLTHellolin