vite-userscript-plugin 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,10 +83,12 @@ Details: [types/README.md](./types/README.md).
83
83
 
84
84
  `vite` prints `/{fileName}.dev.user.js` — install that URL once. HMR covers code and styles.
85
85
 
86
+ `server.file: true` skips HMR. The same `vite` watch-builds `{fileName}.js` (headerless IIFE) and `{fileName}.proxy.user.js` with `@require file://` to that IIFE. Install the printed `/{fileName}.proxy.user.js` URL. See [examples/serve-file](./examples/serve-file).
87
+
86
88
  > [!IMPORTANT]
87
89
  > Changing `@match`, `@grant`, or `@name` needs a reinstall.
88
90
 
89
- `vite build` writes `{fileName}.user.js` to `dist/`.
91
+ `vite build` writes `{fileName}.user.js` to `dist/`. One-shot builds do not emit the proxy.
90
92
 
91
93
  ## Multiple scripts
92
94
 
@@ -133,8 +135,9 @@ See [examples/sourcemap](./examples/sourcemap).
133
135
  | `entry` | — | Userscript entry. Required. |
134
136
  | `header` | — | Metablock. Required: `name`, `version`, `match`. |
135
137
  | `fileName` | sanitized `header.name` | Output base name (`{fileName}.user.js`). |
136
- | `server.open` | `false` | Open the `.dev.user.js` install URL when Vite starts. |
138
+ | `server.open` | `false` | Open the install target when Vite starts. HMR: `.dev.user.js`. `file`: `.proxy.user.js` URL. |
137
139
  | `server.prefix` | `'server:'` | Prefix for `@name` in serve mode. `false` disables it. |
140
+ | `server.file` | `false` | Watch-build `{fileName}.js` + `{fileName}.proxy.user.js` (`@require file://`). Install from the printed URL. No HMR. |
138
141
  | `cssInject` | `'auto'` | How production CSS is injected. `'auto'` uses `GM_addStyle` or a `<style>` node. |
139
142
  | `align` | `1` | Extra spaces after the longest `@key`. `false` — one space. |
140
143
  | `generate` | — | Rewrite the generated metablock. |
@@ -158,6 +161,7 @@ In serve mode the header lists every grant. In production the plugin scans the b
158
161
  | [svelte](./examples/svelte) | SFC `<style>`. |
159
162
  | [multiple-entries](./examples/multiple-entries) | Two scripts. |
160
163
  | [sourcemap](./examples/sourcemap) | Inline map, HTML page, virtual module. |
164
+ | [serve-file](./examples/serve-file) | `server.file`, install the proxy from the printed URL. |
161
165
 
162
166
  ## FAQ
163
167
 
@@ -181,17 +185,22 @@ In serve mode the header lists every grant. In production the plugin scans the b
181
185
  ### `@run-at document-start` feels late in dev
182
186
 
183
187
  > [!NOTE]
184
- > Serve injects `type="module"` (async). Production is a synchronous IIFE unless you use top-level `await`.
188
+ > Serve injects `type="module"` (async). Production is a synchronous IIFE unless you use top-level `await`. `server.file` uses that IIFE in dev too.
189
+
190
+ ### `file://` `@require` is blocked
191
+
192
+ > [!NOTE]
193
+ > Tampermonkey must allow local file access (`@require` from `file://`). Violentmonkey polls the required file after you install `{fileName}.proxy.user.js` from the printed URL. HTTP `@require` and page auto-reload are not part of this mode.
185
194
 
186
195
  ## Migration from v1
187
196
 
188
197
  | v1 | v2 |
189
198
  | --- | --- |
190
- | `vite build --watch` | `vite` |
199
+ | `vite build --watch` | `vite` (HMR) or `vite` + `server.file` |
191
200
  | `esbuildTransformOptions` | removed |
192
201
  | `server.port` | Vite `server.port` |
193
202
  | minify on by default | off; set `build.minify` |
194
- | `*.proxy.user.js` + `file://` | `*.dev.user.js` from Vite |
203
+ | `*.proxy.user.js` + `file://` | `server.file: true`, or HMR `.dev.user.js` |
195
204
  | Vite 3–7 | Vite 8 |
196
205
  | `scripts` + shared `header` | `userscript([config, config, …])` |
197
206
  | `ScriptOptions` | removed |
package/dist/index.d.ts CHANGED
@@ -146,7 +146,8 @@ type HeaderConfig = {
146
146
  };
147
147
  interface ServerConfig {
148
148
  /**
149
- * Open the `.dev.user.js` install URL when the Vite server starts.
149
+ * Open the install target when Vite starts.
150
+ * HMR: `.dev.user.js` URL. `file`: `{fileName}.proxy.user.js` URL.
150
151
  *
151
152
  * @default false
152
153
  */
@@ -158,6 +159,14 @@ interface ServerConfig {
158
159
  * @default 'server:'
159
160
  */
160
161
  prefix?: string | false;
162
+ /**
163
+ * Watch-build into `outDir`: headerless `{fileName}.js` and `{fileName}.proxy.user.js`
164
+ * with `@require file://` pointing at the IIFE.
165
+ * Install the proxy from the printed `/{fileName}.proxy.user.js` URL. No HMR for this script.
166
+ *
167
+ * @default false
168
+ */
169
+ file?: boolean;
161
170
  }
162
171
  type HeaderMode = 'serve' | 'build' | 'meta';
163
172
  interface HeaderGenerateContext {
@@ -229,6 +238,7 @@ interface ResolvedScript {
229
238
  server: {
230
239
  open: boolean;
231
240
  prefix: string | false;
241
+ file: boolean;
232
242
  };
233
243
  cssInject: CssInject;
234
244
  align: number | false;
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { basename, posix, relative, resolve, sep } from "node:path";
2
+ import { build } from "vite";
2
3
  import { Buffer } from "node:buffer";
4
+ import { pathToFileURL } from "node:url";
3
5
  import { existsSync } from "node:fs";
4
- import openLink from "open";
5
6
  import { styleText } from "node:util";
7
+ import openLink from "open";
6
8
  //#region src/names.ts
7
9
  function sanitizeFileName(name) {
8
10
  return name.replace(/[<>:"/\\|?*\u0000-\u001F]+/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "userscript";
@@ -326,6 +328,37 @@ function ensureIife(code) {
326
328
  return `(${/\bawait\b/.test(stripped) ? "async function" : "function"} () {\n${stripped}\n})();\n`;
327
329
  }
328
330
  //#endregion
331
+ //#region src/build/proxy.ts
332
+ function toFileRequireUrl(absPath) {
333
+ return pathToFileURL(absPath).href;
334
+ }
335
+ function toRequireList(value) {
336
+ if (value == null) return [];
337
+ return Array.isArray(value) ? value.map(String) : [String(value)];
338
+ }
339
+ function createWatchProxyHeader(script, jsAbsPath) {
340
+ const header = withServeGrants({ ...script.header });
341
+ return {
342
+ ...header,
343
+ require: [...toRequireList(header.require), toFileRequireUrl(jsAbsPath)]
344
+ };
345
+ }
346
+ function generateWatchProxy(script, jsAbsPath) {
347
+ return generateHeader(createWatchProxyHeader(script, jsAbsPath), {
348
+ align: script.align,
349
+ autoMetaUrls: false,
350
+ fileName: script.fileName,
351
+ generate: script.generate,
352
+ mode: "serve"
353
+ });
354
+ }
355
+ function toProxyFileName(fileName) {
356
+ return `${fileName}.proxy.user.js`;
357
+ }
358
+ function toRequireFileName(fileName) {
359
+ return `${fileName}.js`;
360
+ }
361
+ //#endregion
329
362
  //#region src/build/apply.ts
330
363
  function importedChunkIds(chunk) {
331
364
  return [...chunk.imports, ...chunk.dynamicImports ?? []];
@@ -376,7 +409,8 @@ function findScriptForChunk(chunk, fileName, scripts) {
376
409
  function deleteBundleFiles(bundle, fileNames) {
377
410
  for (const fileName of fileNames) delete bundle[fileName];
378
411
  }
379
- function applyUserscriptBundle(bundle, config, emitMeta) {
412
+ function applyUserscriptBundle(bundle, config, context) {
413
+ const { emitFile } = context;
380
414
  const userscriptEntries = [];
381
415
  const otherEntryFiles = [];
382
416
  for (const [fileName, item] of Object.entries(bundle)) {
@@ -405,8 +439,24 @@ function applyUserscriptBundle(bundle, config, emitMeta) {
405
439
  const body = `${inlined}${chunk.code}`;
406
440
  const wrapped = ensureIife(body);
407
441
  const extraGrants = css && script.cssInject === "auto" ? ["GM_addStyle"] : [];
408
- const headerConfig = withBuildGrants(script.header, wrapped, extraGrants);
409
442
  const code = `${cssPrelude}${wrapped}`;
443
+ const emitFileProxy = Boolean(context.emitProxy && context.outDir && script.server.file);
444
+ leftoverAssets.push(`${fileName}.map`);
445
+ if (emitFileProxy) {
446
+ const requireName = toRequireFileName(script.fileName);
447
+ let nextCode = code.endsWith("\n") ? code : `${code}\n`;
448
+ if (chunk.map) {
449
+ const wrapOffset = isAlreadyIife(stripSourceMappingUrl(body)) ? 0 : 1;
450
+ const lineOffset = countHeaderLines(cssPrelude) + wrapOffset + countHeaderLines(inlined);
451
+ chunk.map = stripVendorSourcesContent(offsetSourceMap(chunk.map, lineOffset, requireName));
452
+ nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(chunk.map)}\n`;
453
+ }
454
+ chunk.code = nextCode;
455
+ chunk.fileName = requireName;
456
+ emitFile(toProxyFileName(script.fileName), `${generateWatchProxy(script, resolve(context.outDir, requireName))}\n`);
457
+ continue;
458
+ }
459
+ const headerConfig = withBuildGrants(script.header, wrapped, extraGrants);
410
460
  const prefix = `${generateHeader(headerConfig, {
411
461
  align: script.align,
412
462
  autoMetaUrls: script.autoMetaUrls,
@@ -422,10 +472,9 @@ function applyUserscriptBundle(bundle, config, emitMeta) {
422
472
  chunk.map = stripVendorSourcesContent(offsetSourceMap(chunk.map, lineOffset, nextFileName));
423
473
  nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(chunk.map)}\n`;
424
474
  }
425
- leftoverAssets.push(`${fileName}.map`);
426
475
  chunk.code = nextCode;
427
476
  chunk.fileName = nextFileName;
428
- if (script.metaFile) emitMeta(`${script.fileName}.meta.js`, generateHeader(headerConfig, {
477
+ if (script.metaFile) emitFile(`${script.fileName}.meta.js`, generateHeader(headerConfig, {
429
478
  align: script.align,
430
479
  autoMetaUrls: script.autoMetaUrls,
431
480
  fileName: script.fileName,
@@ -443,12 +492,16 @@ function applyUserscriptBundle(bundle, config, emitMeta) {
443
492
  const VIRTUAL_MODULE_ID = "virtual:vite-userscript-plugin";
444
493
  const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
445
494
  function createClientSnapshot(scripts, command) {
446
- const suffix = command === "serve" ? ".dev.user.js" : ".user.js";
447
- return scripts.map((script) => ({
448
- name: script.header.name,
449
- version: script.header.version,
450
- file: `${script.fileName}${suffix}`
451
- }));
495
+ return scripts.map((script) => {
496
+ let suffix = ".dev.user.js";
497
+ if (command === "build") suffix = ".user.js";
498
+ else if (script.server.file) suffix = ".proxy.user.js";
499
+ return {
500
+ name: script.header.name,
501
+ version: script.header.version,
502
+ file: `${script.fileName}${suffix}`
503
+ };
504
+ });
452
505
  }
453
506
  function renderVirtualModule(scripts) {
454
507
  return `export const scripts = ${JSON.stringify(scripts)}\n`;
@@ -540,7 +593,8 @@ function toResolvedScript(config) {
540
593
  header: config.header,
541
594
  server: {
542
595
  open: config.server?.open ?? false,
543
- prefix: config.server?.prefix ?? "server:"
596
+ prefix: config.server?.prefix ?? "server:",
597
+ file: config.server?.file ?? false
544
598
  },
545
599
  cssInject: config.cssInject ?? "auto",
546
600
  align: config.align ?? 1,
@@ -598,6 +652,9 @@ function formatInstallLine(installUrl) {
598
652
  const coloredUrl = styleText("cyan", installUrl.replace(/:(\d+)\//, (_match, port) => `:${styleText("bold", port)}/`));
599
653
  return ` ${styleText("green", "➜")} ${styleText("bold", "Userscript")}: ${coloredUrl}`;
600
654
  }
655
+ function formatRebuildLine(elapsedMs) {
656
+ return `${styleText("green", "Userscript rebuilt")} ${styleText("dim", `(${elapsedMs}ms)`)}`;
657
+ }
601
658
  function formatFaqHint() {
602
659
  return `${` ${styleText("green", "➜")} ${styleText("bold", "FAQ")}: `}${styleText("cyan", FAQ_URL)}\n`;
603
660
  }
@@ -660,8 +717,12 @@ import ${JSON.stringify(entryPath)};
660
717
  function matchDevUserscript(url, fileName) {
661
718
  return (url.split("?")[0] ?? "") === `/${fileName}.dev.user.js`;
662
719
  }
663
- function toInstallUrl(origin, fileName) {
664
- return `${origin.replace(/\/$/, "")}/${fileName}.dev.user.js`;
720
+ function matchProxyUserscript(url, fileName) {
721
+ return (url.split("?")[0] ?? "") === `/${toProxyFileName(fileName)}`;
722
+ }
723
+ function toInstallUrl(origin, fileName, file = false) {
724
+ const name = file ? toProxyFileName(fileName) : `${fileName}.dev.user.js`;
725
+ return `${origin.replace(/\/$/, "")}/${name}`;
665
726
  }
666
727
  function toServeEntryPath(root, entry) {
667
728
  const absolute = resolve(root, entry);
@@ -713,7 +774,10 @@ function generateDevUserscript(options) {
713
774
  })}`;
714
775
  }
715
776
  function findDevScript(url, scripts) {
716
- return scripts.find((script) => matchDevUserscript(url, script.fileName));
777
+ return scripts.find((script) => !script.server.file && matchDevUserscript(url, script.fileName));
778
+ }
779
+ function findProxyScript(url, scripts) {
780
+ return scripts.find((script) => script.server.file && matchProxyUserscript(url, script.fileName));
717
781
  }
718
782
  function createDevUserscript(options) {
719
783
  return generateDevUserscript({
@@ -760,6 +824,11 @@ function configureDevServer(server, resolved, reactPreamble) {
760
824
  writeScript(res, createReactBootstrapModule(entry));
761
825
  return;
762
826
  }
827
+ const proxyScript = findProxyScript(url, resolved.scripts);
828
+ if (proxyScript) {
829
+ writeScript(res, `${generateWatchProxy(proxyScript, resolve(server.config.root, server.config.build.outDir, toRequireFileName(proxyScript.fileName)))}\n`);
830
+ return;
831
+ }
763
832
  const script = findDevScript(url, resolved.scripts);
764
833
  if (!script) {
765
834
  next();
@@ -779,7 +848,7 @@ function configureDevServer(server, resolved, reactPreamble) {
779
848
  let origins = [];
780
849
  if (urls) origins = urls.local.length ? urls.local : urls.network;
781
850
  const printInstall = () => {
782
- for (const origin of origins) for (const script of resolved.scripts) info(formatInstallLine(toInstallUrl(origin, script.fileName)));
851
+ for (const origin of origins) for (const script of resolved.scripts) info(formatInstallLine(toInstallUrl(origin, script.fileName, script.server.file)));
783
852
  info(formatFaqHint());
784
853
  };
785
854
  const logger = createAfterLocalLogger(info, urls?.local.length ?? 0, printInstall);
@@ -797,7 +866,7 @@ function configureDevServer(server, resolved, reactPreamble) {
797
866
  if (!toOpen.length) return;
798
867
  queueMicrotask(() => {
799
868
  const origin = resolveServerOrigin(server.resolvedUrls);
800
- for (const script of toOpen) openLink(toInstallUrl(origin, script.fileName));
869
+ for (const script of toOpen) openLink(toInstallUrl(origin, script.fileName, script.server.file));
801
870
  });
802
871
  });
803
872
  }
@@ -813,6 +882,68 @@ function UserscriptPlugin(config) {
813
882
  let resolved = resolvePluginConfig(config);
814
883
  let reactPreamble = false;
815
884
  let command = "serve";
885
+ let isWatch = false;
886
+ let mode = "production";
887
+ let outDir = "";
888
+ let fileWatchStarted = false;
889
+ const shouldEmitProxy = () => {
890
+ return isWatch || mode === "development";
891
+ };
892
+ const startFileWatchBuild = async (server) => {
893
+ if (command === "build" || fileWatchStarted) return;
894
+ fileWatchStarted = true;
895
+ const outDirAbs = resolve(server.config.root, server.config.build.outDir);
896
+ let firstBuild = true;
897
+ const run = async () => {
898
+ const isRebuild = !firstBuild;
899
+ const started = Date.now();
900
+ await build({
901
+ configFile: server.config.configFile ?? false,
902
+ root: server.config.root,
903
+ mode: server.config.mode,
904
+ logLevel: "silent",
905
+ clearScreen: false,
906
+ plugins: server.config.configFile ? void 0 : [UserscriptPlugin(config)],
907
+ build: {
908
+ outDir: server.config.build.outDir,
909
+ emptyOutDir: firstBuild,
910
+ minify: server.config.build.minify,
911
+ sourcemap: server.config.build.sourcemap,
912
+ write: true,
913
+ reportCompressedSize: false
914
+ }
915
+ });
916
+ firstBuild = false;
917
+ if (isRebuild) server.config.logger.info(formatRebuildLine(Date.now() - started), { timestamp: true });
918
+ };
919
+ try {
920
+ await run();
921
+ } catch (error) {
922
+ fileWatchStarted = false;
923
+ server.config.logger.error(`[${PLUGIN_NAME}] Failed to start file-mode watch build`);
924
+ server.config.logger.error(String(error));
925
+ return;
926
+ }
927
+ let timer;
928
+ const onChange = (file) => {
929
+ if (file.startsWith(outDirAbs)) return;
930
+ clearTimeout(timer);
931
+ timer = setTimeout(() => {
932
+ run().catch((error) => {
933
+ server.config.logger.error(String(error));
934
+ });
935
+ }, 80);
936
+ };
937
+ server.watcher.on("change", onChange);
938
+ server.watcher.on("add", onChange);
939
+ const closeServer = server.close.bind(server);
940
+ server.close = async () => {
941
+ server.watcher.off("change", onChange);
942
+ server.watcher.off("add", onChange);
943
+ clearTimeout(timer);
944
+ return closeServer();
945
+ };
946
+ };
816
947
  return [
817
948
  {
818
949
  name: `${PLUGIN_NAME}:config`,
@@ -870,6 +1001,7 @@ function UserscriptPlugin(config) {
870
1001
  apply: "serve",
871
1002
  configureServer(server) {
872
1003
  configureDevServer(server, resolved, reactPreamble);
1004
+ if (resolved.scripts.some((script) => script.server.file)) startFileWatchBuild(server);
873
1005
  }
874
1006
  },
875
1007
  {
@@ -887,13 +1019,23 @@ function UserscriptPlugin(config) {
887
1019
  name: `${PLUGIN_NAME}:build`,
888
1020
  apply: "build",
889
1021
  enforce: "post",
1022
+ configResolved(viteConfig) {
1023
+ isWatch = Boolean(viteConfig.build.watch);
1024
+ mode = viteConfig.mode;
1025
+ outDir = resolve(viteConfig.root, viteConfig.build.outDir);
1026
+ },
890
1027
  generateBundle(_options, bundle) {
891
- applyUserscriptBundle(bundle, resolved, (fileName, source) => {
892
- this.emitFile({
893
- type: "asset",
894
- fileName,
895
- source
896
- });
1028
+ isWatch = this.meta.watchMode || isWatch;
1029
+ applyUserscriptBundle(bundle, resolved, {
1030
+ emitFile: (fileName, source) => {
1031
+ this.emitFile({
1032
+ type: "asset",
1033
+ fileName,
1034
+ source
1035
+ });
1036
+ },
1037
+ emitProxy: shouldEmitProxy(),
1038
+ outDir
897
1039
  });
898
1040
  }
899
1041
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vite-userscript-plugin",
3
3
  "type": "module",
4
- "version": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "author": {
6
6
  "name": "Vitalij Ryndin",
7
7
  "url": "https://github.com/crashmax-dev"