diff --git a/README.md b/README.md index 3b663cd..a407339 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - ๐Ÿ“ Only used `@grant`s in the production build - ๐Ÿ“ฆ Built-in types for Tampermonkey, Greasemonkey and Violentmonkey - ๐Ÿ“„ Virtual module with script metadata +- ๐Ÿงต Support Web Workers ## Getting started @@ -107,6 +108,14 @@ import './style.css' > [!NOTE] > Do not put userscript assets in `public/` โ€” those URLs hit the host site and 404. Import the file so Vite inlines it. +## Web Workers + +HMR serve rewrites `import Worker from './w?worker'` (and `?worker&inline`) to a data-URI module worker that imports Vite's `?worker_file`. The host page cannot load `/src/w.ts?worker_file` from localhost. + +`vite build` and `server.file` keep Vite's worker emit. A plain `?worker` is a separate file the match site cannot serve โ€” use `?worker&inline`. `?worker&url` and `?sharedworker` stay as Vite emits them. + +See [examples/web-worker](./examples/web-worker). + ## HTML pages `index.html` is a normal Vite app next to the userscript. `vite` serves it at `/`. `vite build` writes it to `dist/` beside `{fileName}.user.js`. @@ -184,6 +193,7 @@ In serve mode the header lists every grant. In production the plugin scans the b | [sourcemap](./examples/sourcemap) | Inline map, HTML page, virtual module. | | [serve-file](./examples/serve-file) | `server.file`, install `.user.js` or the proxy from the printed URLs. | | [external-cdn](./examples/external-cdn) | `@types/jquery` only; runtime `$` from a CDN `@require`. | +| [web-worker](./examples/web-worker) | `?worker&inline`, data-URI bridge in HMR. | ## FAQ diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index c390bc8..48761b0 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - } - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/external-cdn/tsconfig.json b/examples/external-cdn/tsconfig.json index 15b8086..48761b0 100644 --- a/examples/external-cdn/tsconfig.json +++ b/examples/external-cdn/tsconfig.json @@ -1,10 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - }, - "allowSyntheticDefaultImports": true - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/multiple-entries/tsconfig.json b/examples/multiple-entries/tsconfig.json index c390bc8..48761b0 100644 --- a/examples/multiple-entries/tsconfig.json +++ b/examples/multiple-entries/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - } - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json index bd8322e..a079e2e 100644 --- a/examples/react/tsconfig.json +++ b/examples/react/tsconfig.json @@ -1,11 +1,10 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../tsconfig.base.json", "compilerOptions": { - "jsx": "react-jsx", - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - }, - "types": ["vite-userscript-plugin/types/tampermonkey"] + "jsx": "react-jsx" }, - "include": ["src/**/*", "vite.config.ts"] + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/serve-file/tsconfig.json b/examples/serve-file/tsconfig.json index c390bc8..48761b0 100644 --- a/examples/serve-file/tsconfig.json +++ b/examples/serve-file/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - } - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/sourcemap/tsconfig.json b/examples/sourcemap/tsconfig.json index d6ba25b..48761b0 100644 --- a/examples/sourcemap/tsconfig.json +++ b/examples/sourcemap/tsconfig.json @@ -1,10 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"], - "vite-userscript-plugin/virtual": ["../../virtual.d.ts"] - } - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/svelte/tsconfig.json b/examples/svelte/tsconfig.json index c390bc8..48761b0 100644 --- a/examples/svelte/tsconfig.json +++ b/examples/svelte/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - } - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/tsconfig.base.json b/examples/tsconfig.base.json new file mode 100644 index 0000000..adefe8b --- /dev/null +++ b/examples/tsconfig.base.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "paths": { + "vite-userscript-plugin": ["../src/index.ts"], + "vite-userscript-plugin/virtual": ["../src/virtual.ts"] + } + } +} diff --git a/examples/vue/tsconfig.json b/examples/vue/tsconfig.json index c390bc8..48761b0 100644 --- a/examples/vue/tsconfig.json +++ b/examples/vue/tsconfig.json @@ -1,9 +1,7 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "paths": { - "vite-userscript-plugin": ["../../src/index.ts"] - } - }, - "include": ["src/**/*", "vite.config.ts"] + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/examples/web-worker/README.md b/examples/web-worker/README.md new file mode 100644 index 0000000..18451f7 --- /dev/null +++ b/examples/web-worker/README.md @@ -0,0 +1,12 @@ +# Web Worker example + +Vanilla userscript that reverses text in a module worker via `import Worker from './echo.ts?worker&inline'`. + +HMR serve wraps `?worker` / `?worker&inline` in a data-URI so the host page can start the worker. `pnpm build` needs `&inline` โ€” a plain `?worker` emits a separate file the match site cannot serve. + +```bash +pnpm dev +pnpm build +``` + +Install the printed `*.dev.user.js` URL. HTTPS and `public/` notes: [FAQ](../../README.md#faq). diff --git a/examples/web-worker/package.json b/examples/web-worker/package.json new file mode 100644 index 0000000..93e4334 --- /dev/null +++ b/examples/web-worker/package.json @@ -0,0 +1,15 @@ +{ + "name": "web-worker-example", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "type-check": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "vite": "catalog:", + "vite-userscript-plugin": "workspace:*" + } +} diff --git a/examples/web-worker/src/echo.ts b/examples/web-worker/src/echo.ts new file mode 100644 index 0000000..64efb6e --- /dev/null +++ b/examples/web-worker/src/echo.ts @@ -0,0 +1,10 @@ +import type { EchoRequest, EchoResponse } from './protocol' + +addEventListener('message', (event: MessageEvent) => { + const { text } = event.data + const response: EchoResponse = { + text, + reversed: [...text].reverse().join(''), + } + postMessage(response) +}) diff --git a/examples/web-worker/src/index.ts b/examples/web-worker/src/index.ts new file mode 100644 index 0000000..f7c17c9 --- /dev/null +++ b/examples/web-worker/src/index.ts @@ -0,0 +1,6 @@ +import { createWidget } from './widget' +import './style.css' + +if (document.body) { + document.body.append(createWidget()) +} diff --git a/examples/web-worker/src/protocol.ts b/examples/web-worker/src/protocol.ts new file mode 100644 index 0000000..3f5fd3d --- /dev/null +++ b/examples/web-worker/src/protocol.ts @@ -0,0 +1,8 @@ +export interface EchoRequest { + text: string +} + +export interface EchoResponse { + text: string + reversed: string +} diff --git a/examples/web-worker/src/style.css b/examples/web-worker/src/style.css new file mode 100644 index 0000000..409d7af --- /dev/null +++ b/examples/web-worker/src/style.css @@ -0,0 +1,21 @@ +.web-worker-userscript { + border-top: 1px solid black; +} + +.web-worker-userscript .meta { + color: #555; +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.web-worker-userscript .log { + margin: 0.75rem 0 0; + padding-left: 1.25rem; +} + +.web-worker-userscript .log li { + margin: 0.25rem 0; +} diff --git a/examples/web-worker/src/vite-env.d.ts b/examples/web-worker/src/vite-env.d.ts new file mode 100644 index 0000000..25b58a6 --- /dev/null +++ b/examples/web-worker/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/examples/web-worker/src/widget.ts b/examples/web-worker/src/widget.ts new file mode 100644 index 0000000..6cdd87a --- /dev/null +++ b/examples/web-worker/src/widget.ts @@ -0,0 +1,65 @@ +import type { EchoRequest, EchoResponse } from './protocol' +import EchoWorker from './echo.ts?worker&inline' + +function createButton(text: string, onClick: () => void) { + const button = document.createElement('button') + button.type = 'button' + button.textContent = text + button.addEventListener('click', onClick) + return button +} + +export function createWidget() { + const worker = new EchoWorker({ name: 'echo' }) + + const root = document.createElement('div') + root.className = 'web-worker-userscript' + + const title = document.createElement('h1') + title.textContent = 'Web Worker userscript' + + const meta = document.createElement('p') + meta.className = 'meta' + meta.textContent = 'Reverses text in a module worker (`?worker&inline`)' + + const input = document.createElement('input') + input.type = 'text' + input.value = 'userscript' + input.setAttribute('aria-label', 'Text to reverse') + + const log = document.createElement('ol') + log.className = 'log' + log.setAttribute('aria-live', 'polite') + + const appendLog = (text: string) => { + const item = document.createElement('li') + item.textContent = text + log.append(item) + } + + const send = () => { + const text = input.value.trim() + if (!text) return + + const request: EchoRequest = { + text, + } + worker.postMessage(request) + } + + worker.addEventListener('message', (event: MessageEvent) => { + const { text, reversed } = event.data + appendLog(`${text} โ†’ ${reversed}`) + }) + + worker.addEventListener('error', () => { + appendLog('Worker failed to start') + }) + + const actions = document.createElement('div') + actions.className = 'actions' + actions.append(input, createButton('Reverse', send)) + + root.append(title, meta, actions, log) + return root +} diff --git a/examples/web-worker/tsconfig.json b/examples/web-worker/tsconfig.json new file mode 100644 index 0000000..48761b0 --- /dev/null +++ b/examples/web-worker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] +} diff --git a/examples/web-worker/vite.config.ts b/examples/web-worker/vite.config.ts new file mode 100644 index 0000000..78c8ab5 --- /dev/null +++ b/examples/web-worker/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import userscript from 'vite-userscript-plugin' +import pkg from './package.json' with { type: 'json' } + +export default defineConfig({ + plugins: [ + userscript({ + entry: 'src/index.ts', + header: { + name: pkg.name, + version: pkg.version, + match: 'https://example.com/', + }, + }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a04927..1779b5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,15 @@ importers: specifier: 'catalog:' version: 3.5.42(@typescript/typescript6@6.0.2) + examples/web-worker: + devDependencies: + vite: + specifier: 'catalog:' + version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.0)(sass@1.104.0)(yaml@2.9.0) + vite-userscript-plugin: + specifier: workspace:* + version: link:../.. + packages: '@antfu/eslint-config@7.2.0': @@ -3621,7 +3630,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: '@parcel/watcher-android-arm64': 2.6.0 '@parcel/watcher-darwin-arm64': 2.6.0 @@ -4746,9 +4755,9 @@ snapshots: dependencies: format: 0.2.2 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 fflate@0.8.3: {} @@ -5755,8 +5764,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@2.1.0: {} diff --git a/src/plugin.ts b/src/plugin.ts index e641971..cce0272 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -29,6 +29,7 @@ import { formatRebuildLine } from './serve/logger.js' import { configureDevServer } from './serve/middleware.js' import { hasReactRefreshPlugin } from './serve/react.js' import { createDebouncedSingleFlight } from './serve/watch-queue.js' +import { isWebWorkerRequest, webWorkerWrapper } from './serve/web-worker.js' import { toInstallPath } from './serve/wrapper.js' function absolutizeEntries( @@ -249,6 +250,16 @@ function UserscriptPlugin(config: UserscriptPluginConfig): Plugin[] { return renderExternalModule(external.global) }, }, + { + name: `${PLUGIN_NAME}:web-worker`, + enforce: 'pre', + apply: 'serve', + load(id) { + if (isWebWorkerRequest(id)) { + return webWorkerWrapper + } + }, + }, { name: `${PLUGIN_NAME}:serve`, apply: 'serve', diff --git a/src/serve/gm-shim.ts b/src/serve/gm-shim.ts index ca3a4d6..7867076 100644 --- a/src/serve/gm-shim.ts +++ b/src/serve/gm-shim.ts @@ -25,6 +25,10 @@ export function shouldShimModule(id: string): boolean { return false } + if (/[?&](?:worker_file|sharedworker|worker)(?:&|$)/.test(cleanId)) { + return false + } + if (/\.(?:m|c)?[jt]sx?(?:$|\?)/.test(cleanId)) { return true } diff --git a/src/serve/index.ts b/src/serve/index.ts index 1ec7016..390280b 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -6,7 +6,11 @@ export { renderExternalModule, toExternalModuleId, } from './external.js' -export { createGmShimPrelude, shimModule, shouldShimModule } from './gm-shim.js' +export { + createGmShimPrelude, + shimModule, + shouldShimModule, +} from './gm-shim.js' export { alignViteUrlLine, createAfterLocalLogger, @@ -16,7 +20,11 @@ export { isViteLocalUrlLine, stripAnsi, } from './logger.js' -export { configureDevServer, DEV_SCRIPT_HEADERS, resolveServerOrigin } from './middleware.js' +export { + configureDevServer, + DEV_SCRIPT_HEADERS, + resolveServerOrigin, +} from './middleware.js' export { createReactBootstrapModule, hasReactRefreshPlugin, @@ -27,6 +35,7 @@ export { } from './react.js' export type { DebouncedSingleFlight } from './watch-queue.js' export { createDebouncedSingleFlight } from './watch-queue.js' +export { isWebWorkerRequest, webWorkerWrapper } from './web-worker.js' export type { InstallKind } from './wrapper.js' export { applyServeHeader, diff --git a/src/serve/web-worker.ts b/src/serve/web-worker.ts new file mode 100644 index 0000000..ab37a21 --- /dev/null +++ b/src/serve/web-worker.ts @@ -0,0 +1,22 @@ +export function isWebWorkerRequest(id: string): boolean { + const queryIndex = id.indexOf('?') + if (queryIndex === -1) { + return false + } + + const query = new URLSearchParams(id.slice(queryIndex + 1)) + return query.has('worker') && !query.has('url') +} + +export const webWorkerWrapper = `const dataUri = \`data:text/javascript;charset=utf-8,\${encodeURIComponent( + \`import \${JSON.stringify( + new URL('?worker_file&type=module', import.meta['url']).href, + )};\`, +)}\`; + +export default function WorkerWrapper(options) { + return new Worker(dataUri, { + type: 'module', + name: options?.name, + }); +}` diff --git a/test/fixtures/web-worker/src/echo.ts b/test/fixtures/web-worker/src/echo.ts new file mode 100644 index 0000000..00fa16b --- /dev/null +++ b/test/fixtures/web-worker/src/echo.ts @@ -0,0 +1,3 @@ +addEventListener('message', (event: MessageEvent) => { + postMessage(event.data) +}) diff --git a/test/fixtures/web-worker/src/main.ts b/test/fixtures/web-worker/src/main.ts new file mode 100644 index 0000000..05e0ca3 --- /dev/null +++ b/test/fixtures/web-worker/src/main.ts @@ -0,0 +1,4 @@ +import EchoWorker from './echo.ts?worker' + +const worker = new EchoWorker() +worker.postMessage('userscript-worker') diff --git a/test/gm-shim.test.ts b/test/gm-shim.test.ts index b3e8bf2..16dffa9 100644 --- a/test/gm-shim.test.ts +++ b/test/gm-shim.test.ts @@ -25,6 +25,14 @@ it('shouldShimModule rejects styles, raw queries and node_modules', () => { expect(shouldShimModule('/node_modules/vue/dist/vue.js')).toBe(false) }) +it('shouldShimModule rejects worker queries', () => { + expect(shouldShimModule('/src/echo.ts?worker')).toBe(false) + expect(shouldShimModule('/src/echo.ts?worker&inline')).toBe(false) + expect(shouldShimModule('/src/echo.ts?worker&url')).toBe(false) + expect(shouldShimModule('/src/echo.ts?worker_file&type=module')).toBe(false) + expect(shouldShimModule('/src/echo.ts?sharedworker')).toBe(false) +}) + it('shimModule offsets the sourcemap past the prelude', () => { const code = 'console.log(2)\n\nthrow new Error("sourcemap")\n\nconsole.log(1)\n' const shimmed = shimModule(code, '/src/counter.ts') diff --git a/test/serve-web-worker.integration.test.ts b/test/serve-web-worker.integration.test.ts new file mode 100644 index 0000000..9078c1d --- /dev/null +++ b/test/serve-web-worker.integration.test.ts @@ -0,0 +1,49 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import { expect, it } from 'vitest' +import userscript from '../src/index.js' + +const fixtures = fileURLToPath(new URL('./fixtures', import.meta.url)) + +it('serve wraps ?worker as a data-URI module web worker', async () => { + const server = await createServer({ + root: join(fixtures, 'web-worker'), + configFile: false, + logLevel: 'silent', + plugins: [ + userscript({ + entry: 'src/main.ts', + fileName: 'worker-app', + header: { + name: 'Worker App', + version: '1.0.0', + match: 'https://example.com/*', + }, + }), + ], + }) + + try { + await server.listen() + + const worker = await server.transformRequest('/src/echo.ts?worker') + expect(worker?.code).toContain('data:text/javascript') + expect(worker?.code).toMatch(/import\.meta\[['"]url['"]\]/) + expect(worker?.code).toContain('?worker_file&type=module') + expect(worker?.code).toContain('function WorkerWrapper') + + const inline = await server.transformRequest('/src/echo.ts?worker&inline') + expect(inline?.code).toContain('data:text/javascript') + expect(inline?.code).toMatch(/import\.meta\[['"]url['"]\]/) + expect(inline?.code).toContain('?worker_file&type=module') + + const url = await server.transformRequest('/src/echo.ts?worker&url') + expect(url?.code).toBeTruthy() + expect(url?.code).not.toContain('data:text/javascript') + expect(url?.code).not.toMatch(/import\.meta\[['"]url['"]\]/) + expect(url?.code).toContain('worker_file') + } finally { + await server.close() + } +}) diff --git a/test/serve.test.ts b/test/serve.test.ts index e2c3ee4..f914144 100644 --- a/test/serve.test.ts +++ b/test/serve.test.ts @@ -24,6 +24,7 @@ import { generateDevWrapper, hasReactRefreshPlugin, isViteLocalUrlLine, + isWebWorkerRequest, matchDevUserscript, matchFileUserscript, matchProxyUserscript, @@ -36,6 +37,7 @@ import { toInstallPath, toInstallUrl, toServeEntryPath, + webWorkerWrapper, } from '../src/serve/index.js' it('matchDevUserscript matches install path', () => { @@ -409,6 +411,23 @@ it('createAfterLocalLogger aligns ANSI Local lines', () => { expect(stripAnsi(lines[0] ?? '').indexOf('http')).toBe(install.indexOf('http')) }) +it('isWebWorkerRequest matches constructor worker queries', () => { + expect(isWebWorkerRequest('/src/echo.ts?worker')).toBe(true) + expect(isWebWorkerRequest('/src/echo.ts?worker&inline')).toBe(true) + expect(isWebWorkerRequest('/src/echo.ts?inline&worker')).toBe(true) + expect(isWebWorkerRequest('/src/echo.ts?worker&url')).toBe(false) + expect(isWebWorkerRequest('/src/echo.ts?sharedworker')).toBe(false) + expect(isWebWorkerRequest('/src/echo.ts?worker_file&type=module')).toBe(false) + expect(isWebWorkerRequest('/src/echo.ts')).toBe(false) +}) + +it('webWorkerWrapper is a data-URI module worker', () => { + expect(webWorkerWrapper).toContain('data:text/javascript') + expect(webWorkerWrapper).toContain(`import.meta['url']`) + expect(webWorkerWrapper).toContain('?worker_file&type=module') + expect(webWorkerWrapper).toContain('function WorkerWrapper') +}) + it('resolveServerOrigin prefers the first local URL', () => { expect(resolveServerOrigin({ local: ['http://localhost:5173/', 'http://127.0.0.1:5173/'], diff --git a/vitest.config.ts b/vitest.config.ts index 00dfd76..476253f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,11 @@ export default defineConfig({ test: { name: 'integration', include: ['test/**/*.integration.test.ts'], - exclude: ['**/node_modules/**', '**/dist/**', '**/examples/**'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/examples/**', + ], testTimeout: 30_000, }, },