跪拜 Guibai
← Back to the summary

Tampermonkey Web Workers Break in Dev: Same-Origin Traps and Vite Workarounds

1. Web Workers cannot directly use non-same-origin resources

Suppose Google has a w.js script. On your website, you cannot directly use new Worker('https://www.google.com/w.js') to load it. Note that this is a same-origin restriction and has nothing to do with CORS.

2. Worker import methods

1. new Worker()

The traditional way to import a worker is new Worker(''). What does new Worker do? It initiates an HTTP request to fetch this worker file from the current site. (This requires the file to actually exist.)

2. new Worker(BlobURL)

There is another variant of the traditional worker import method: obtain the JavaScript string, create a blob via new Blob([jsCodeString], { type: "application/javascript" }), then generate a blob URL using URL.createObjectURL(blob), and finally new Worker(blob url). This way, the file does not need to physically exist.

const jsContent = `const a = 1;console.log(a)`
const blob = new Blob([jsCodeString], { type: "application/javascript" })
const url = URL.createObjectURL(blob)
new Worker(url)

3. new Worker(new URL('./w.js', import.meta.url).href, { type: 'module' })

new Worker('') resolves the path based on the browser address bar page URL. However, with the rise of applications like Vue/React, the deep History routing of these SPAs (/user/detail/123) can concatenate incorrect paths, causing the worker file to not be found. Therefore, Chrome introduced using import.meta.url in 2020 to locate the worker file. Regardless of how deep the page route is or which level the page is on, it always searches for the worker relative to the current script file's directory. However, this must be executed inside a script module.

<script type="module">
new Worker(new URL('./w.js', import.meta.url).href, { type: 'module' })
</script>

4. import Worker from './worker?worker&inline'

This is Vite's syntax for supporting the inlining of external worker files into the code.

The principle is the second method: first convert the external JS file into a code string, then inline it using Blob. The difference is that you don't have to handle this process manually; Vite does it for you.

3. Code in Practice

Following the order above, I had AI write 4 sets of code and tested them separately for JS/TS.

image.png

The specific code implementations are as follows:

A-js

const w = new Worker('./fib-worker.js');

A-ts

const w = new Worker('./fib-worker.ts');

B-js

import jsWorkerCode from './fib-worker.js?raw';

const blob = new Blob([jsWorkerCode], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const w = new Worker(url);
URL.revokeObjectURL(url);

B-ts

import tsWorkerCode from './fib-worker.ts?raw';

const blob = new Blob([tsWorkerCode], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const w = new Worker(url);
URL.revokeObjectURL(url);

C-js

const url = new URL('./fib-worker.js', import.meta.url).href;
const w = new Worker(url, { type: 'module' });

C-ts

const url = new URL('./fib-worker.ts', import.meta.url).href;
const w = new Worker(url, { type: 'module' });

D-js

import FibWorkerJS from './fib-worker.js?worker&inline';

const w = new FibWorkerJS();

D-ts

import FibWorkerTS from './fib-worker.ts?worker&inline';

const w = new FibWorkerTS();

The following was run using vite-plugin-monkey on the Juejin page in a development environment:

image.png image.png

Now to explain:

A-js initiated an HTTP request to Juejin to fetch this worker file, but the file does not exist. Juejin returned a 200 HTML response <!DOCTYPE html><html>. The Worker then tried to execute it, found the first line was <, and thus threw a specific syntax error:

image.png

Note that it's not guaranteed that a missing worker file will return HTML; it depends on the website's handling. If the website returns HTML for a non-existent resource, this is the result. If the website doesn't handle it, the request will remain pending indefinitely, triggering a browser error.

image.png

A-ts is the same as A-js.

B-js is the standard inline JS worker, so it can run.

B-ts failed because the imported TS source code contains some types, and the browser does not support parsing these types, resulting in an error. (Sometimes people import TS and it succeeds because the file contains no types, and the browser parses it as JS.)

C-js is similar to A-js, both using new Worker(). Why is the error different? The issue lies with import.meta.url. At this point, the worker is not making an HTTP request to Juejin, but to the local Vite server of vite-plugin-monkey. It is actually new Worker('http://127.0.0.1:5173/src/fib-worker.js'), which violates the first rule: Web Workers cannot directly use non-same-origin resources.

C-ts is the same as above.

D-js is supposed to inline the worker, so why is it still requesting a non-existent worker like A-js? We can open the panel and see this request.

image.png

The content inside is like this:

image.png

That is to say, because Vite does not bundle in the development environment, it only wraps it simply. Writing it this way still results in an HTTP request being made to Juejin. Since Juejin does not handle this request, it remains pending, thus triggering the browser's error event.

image.png

4. Build Results

image.png

image.png

We can see that C and D have changed. C-js failed because Tampermonkey bundles the output as a regular script, running via IIFE, so the outer script has no module context, causing import.meta.url to be undefined here, thus failing to construct the URL.

D, on the other hand, is correctly inlined in the code, so it can run normally after bundling.

5. How to use ts+webworker in a development environment?

We can see that in the development environment, there is only one way, which is to use JS inlining. But I want it to support the rich type hints of TS. I can't just bundle and test every time I make a small change, right? This makes debugging too troublesome. I've tried it before: make a small change -> bundle -> delete the old Tampermonkey script -> replace with the new script. It's very tedious.

Actually, I've seen some community-written plugins that, upon detecting ?worker&inline, bundle it first and inline it directly. That works, but it still feels inelegant.

Finally, I found another way:

const url = new URL('./fib-worker.ts', import.meta.url).href
const res = await fetch(url)
const jsCodeString = await res.text()
const blob = new Blob([jsCodeString], { type: 'application/javascript' })
const blobUrl = URL.createObjectURL(blob)
const w = new Worker(blobUrl)

image.png

This involves the principle of Vite's server. When developing Vue/React projects in a development environment, you will find that the browser initiates many requests, like for vue, ts, tsx, etc.

image.png

But how does the browser recognize vue or ts? That's because when the browser requests a vue file from Vite, Vite doesn't return the vue file directly. Instead, it first converts the vue file into a js file using @vue/compiler-sfc, and then returns it.

image.png

Similarly, when the browser requests a ts file from Vite, Vite first performs type erasure. After type erasure, isn't it just a js file? So no compilation is needed, and it returns directly.

image.png

Now, back to the previous question. const url = new URL('./fib-worker.ts', import.meta.url).href actually gets the address of the ts file on the Vite server: http://127.0.0.1:5173/src/fib-worker.ts

image.png

And as just mentioned, requesting ts from the Vite server causes Vite to automatically perform type erasure, so what you get is a pure js file.

image.png

Since we can get a pure js file, we need to obtain it. We fetch it, and the Vite server is configured to allow cross-origin requests, so we get the pure jsCodeString. Now that we have the jsCodeString, among the demos demonstrated in the development environment above, only B-js was successful. So we just need to convert it to a Blob URL to use web workers in the development environment. However, in the production environment, we don't need to fetch. So this is a combination of B-js and D-ts.

The complete wrapper is as follows:

import TSWorker from './worker?worker&inline'

async function createWorker(): Promise<Worker> {
    if (import.meta.env.DEV) {
        const url = new URL('./worker.ts', import.meta.url).href
        const res = await fetch(url)
        if (!res.ok) throw new Error('Failed to get worker code')
        const jsCodeString = await res.text()
        const blob = new Blob([jsCodeString], { type: 'application/javascript' })
        return new Worker(URL.createObjectURL(blob))
    }
    return new TSWorker()
}

await createWorker()

In the development environment, it uses fetch; during bundling, it uses Vite's worker inline.

6. Omitted Issues

image.png This is very accurate. The script above is only suitable for wrapping simple workers. If the worker imports other files or other packages, it will fail. Because after converting to a jsCodeString, it becomes static, and it cannot analyze where these packages come from. So we return to the plugin approach. Here, I'll post the plugin implementation:

import type { Plugin } from 'vite'

/**
 * Fixes the issue where `?worker&inline` workers do not work in the development environment.
 *
 * In dev mode, Vite does not bundle workers and generates a WorkerWrapper with an external URL.
 * Tampermonkey cannot load external files → worker fails silently.
 *
 * This plugin intercepts the generated wrapper and replaces the external URL with an inline data URI.
 * The worker code is bundled into an IIFE via rolldown.
 */
export function fixDevWebworker(): Plugin {
    return {
        name: 'fix-dev-webworker',
        enforce: 'post',
        apply: 'serve',

        async transform(code, id) {
            if (!id.endsWith('?worker&inline')) return null

            const cleanPath = id.replace(/\?worker&inline$/, '')

            const { rolldown } = await import('rolldown')
            const bundle = await rolldown({
                input: cleanPath,
                platform: 'browser',
            })

            let bundledCode: string
            try {
                const result = await bundle.generate({ format: 'iife' })
                bundledCode = result.output[0].code
            } finally {
                await bundle.close()
            }

            // Vite's WorkerWrapper contains an external URL like ".../xxx?worker_file...",
            // Replace it with an IIFE: create Blob URL → Worker loads synchronously → revoke
            return code.replace(
                /"\/[^"]+"/,
                `(()=>{const u=URL.createObjectURL(new Blob([${JSON.stringify(bundledCode)}],{type:'application/javascript'}));setTimeout(()=>URL.revokeObjectURL(u),0);return u})()`,
            )
        },
    }
}

Just import it after monkey:

// vite.config.ts
export default defineConfig({
    plugins: [
        ...,
        monkey({
            ...
        }),
        fixDevWebworker(),
    ],
})

Let's look at the actual request content:

image.png

At this point, it is in the inline form of B-js, and it can automatically URL.revokeObjectURL.

Comments

Top 1 of 4 from juejin.cn, machine-translated. The original thread is authoritative.

增量编译

if your work.ts imports other files, it won't work

天平

I tried it, yes, you still have to use a plugin to handle this

增量编译  → 天平

vite8 recently released a bundledDev, you can try it