vite-userscript-plugin 2.3.1 → 2.4.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
@@ -111,6 +111,8 @@ import './style.css'
111
111
 
112
112
  `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`.
113
113
 
114
+ The plugin sets `build.assetsInlineLimit` very high so userscript assets become data URLs. The HTML app’s assets will too, unless you set `build.assetsInlineLimit` yourself.
115
+
114
116
  > [!WARNING]
115
117
  > Keep the page's `<script>` entries distinct from `entry`.
116
118
 
@@ -142,10 +144,30 @@ See [examples/sourcemap](./examples/sourcemap).
142
144
  | `generate` | — | Rewrite the generated metablock. |
143
145
  | `autoMetaUrls` | `false` | Fill empty `updateURL` / `downloadURL` from `homepage` / `homepageURL` / `website` / `source`. |
144
146
  | `metaFile` | `true` | Emit `{fileName}.meta.js`. |
147
+ | `external` | — | Keep these packages out of the bundle and load them via `@require`. Keys are specifiers (`jquery`, `vue`). A string value is the CDN URL (global name from the specifier). Pass `{ global, url }` for `$` / `Vue`. Install `@types/…` for `tsc`; do not install the runtime package. See [examples/external-cdn](./examples/external-cdn). |
145
148
 
146
149
  Everything else on `header` follows the manager metablock (`@grant`, `@require`, `@connect`, …).
147
150
 
148
- In serve mode the header lists every grant. In production the plugin scans the bundle and writes only the grants in use. `grant: "none"` disables GM APIs and is never mixed with the scan.
151
+ ```ts
152
+ userscript({
153
+ entry: 'src/index.ts',
154
+ header: {
155
+ name: pkg.name,
156
+ version: pkg.version,
157
+ match: 'https://example.com/*',
158
+ },
159
+ external: {
160
+ jquery: {
161
+ global: '$',
162
+ url: 'https://cdn.jsdelivr.net/npm/jquery@4.0.0/dist/jquery.min.js',
163
+ },
164
+ },
165
+ })
166
+ ```
167
+
168
+ For types without bundling the package: add `@types/jquery` (not `jquery`) and a `d.ts` that references those types. The import type-checks; the plugin maps it to the CDN global. Full setup: [examples/external-cdn](./examples/external-cdn).
169
+
170
+ In serve mode the header lists every grant. In production the plugin scans the bundle and writes only the grants in use. `window.focus`, `window.close`, and `window.onurlchange` are **not** auto-detected (they collide with DOM APIs) — list them in `header.grant` when you need them. `grant: "none"` disables GM APIs and is never mixed with the scan.
149
171
 
150
172
  > [!WARNING]
151
173
  > Keep `metaFile: true` if you use `autoMetaUrls`. Otherwise `@updateURL` points at a file that is not emitted.
@@ -161,6 +183,7 @@ In serve mode the header lists every grant. In production the plugin scans the b
161
183
  | [multiple-entries](./examples/multiple-entries) | Two scripts. |
162
184
  | [sourcemap](./examples/sourcemap) | Inline map, HTML page, virtual module. |
163
185
  | [serve-file](./examples/serve-file) | `server.file`, install `.user.js` or the proxy from the printed URLs. |
186
+ | [external-cdn](./examples/external-cdn) | `@types/jquery` only; runtime `$` from a CDN `@require`. |
164
187
 
165
188
  ## FAQ
166
189
 
package/dist/index.d.ts CHANGED
@@ -195,6 +195,22 @@ interface HeaderGenerateContext {
195
195
  userscript: string;
196
196
  mode: HeaderMode;
197
197
  }
198
+ interface ExternalRequire {
199
+ /**
200
+ * Global name the `@require` script assigns (e.g. `$`, `Vue`).
201
+ */
202
+ global: string;
203
+ /**
204
+ * Absolute URL added to `@require`.
205
+ */
206
+ url: string;
207
+ }
208
+ type UserscriptExternal = Record<string, string | ExternalRequire>;
209
+ interface ResolvedExternal {
210
+ specifier: string;
211
+ global: string;
212
+ url: string;
213
+ }
198
214
  /**
199
215
  * One userscript. Pass an object, or an array of these, to {@link UserscriptPluginConfig}.
200
216
  */
@@ -243,12 +259,21 @@ interface UserscriptConfig {
243
259
  * @default true
244
260
  */
245
261
  metaFile?: boolean;
262
+ /**
263
+ * Keep these packages out of the bundle and load them via `@require`.
264
+ * Keys are specifiers (`jquery`, `vue`). A string value is the CDN URL; the
265
+ * global name is derived from the specifier. Pass `{ global, url }` for `$` / `Vue`.
266
+ *
267
+ * Install `@types/…` (or a types-only package) for `tsc`. Do not install the
268
+ * runtime package — serve maps the specifier to the CDN global, build rewrites
269
+ * the import. See `examples/external-cdn`.
270
+ */
271
+ external?: UserscriptExternal;
246
272
  }
247
273
  type UserscriptPluginConfig = UserscriptConfig | UserscriptConfig[];
248
274
  interface ResolvedScript {
249
275
  entry: string;
250
276
  fileName: string;
251
- iifeName: string;
252
277
  header: HeaderConfig;
253
278
  server: {
254
279
  open: ResolvedServerOpen;
@@ -259,9 +284,10 @@ interface ResolvedScript {
259
284
  generate?: (ctx: HeaderGenerateContext) => string;
260
285
  autoMetaUrls: boolean;
261
286
  metaFile: boolean;
287
+ external: ResolvedExternal[];
262
288
  }
263
289
  //#endregion
264
290
  //#region src/plugin.d.ts
265
291
  declare function UserscriptPlugin(config: UserscriptPluginConfig): Plugin[];
266
292
  //#endregion
267
- export { type HeaderConfig, type HeaderGenerateContext, type ResolvedScript, type ResolvedServerOpen, type ServerConfig, type ServerOpen, type UserscriptConfig, type UserscriptPluginConfig, UserscriptPlugin as default };
293
+ export { type ExternalRequire, type HeaderConfig, type HeaderGenerateContext, type ResolvedExternal, type ResolvedScript, type ResolvedServerOpen, type ServerConfig, type ServerOpen, type UserscriptConfig, type UserscriptExternal, type UserscriptPluginConfig, UserscriptPlugin as default };
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ const gmIdentifiers = [
54
54
  ];
55
55
  //#endregion
56
56
  //#region src/grants/scan.ts
57
- const grantMatchers = grants.map((grant) => ({
57
+ const grantMatchers = grants.filter((grant) => !grant.startsWith("window.")).map((grant) => ({
58
58
  grant,
59
59
  pattern: new RegExp(`\\b${grant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)
60
60
  }));
@@ -74,15 +74,11 @@ function withServeGrants(header) {
74
74
  grant: [.../* @__PURE__ */ new Set([...header.grant ?? [], ...grants])]
75
75
  };
76
76
  }
77
- function withBuildGrants(header, code, extraGrants = []) {
77
+ function withBuildGrants(header, code) {
78
78
  if (header.grant === "none") return header;
79
79
  return {
80
80
  ...header,
81
- grant: removeDuplicates([
82
- ...defineGrants(code),
83
- ...removeDuplicates(header.grant),
84
- ...extraGrants
85
- ])
81
+ grant: removeDuplicates([...defineGrants(code), ...removeDuplicates(header.grant)])
86
82
  };
87
83
  }
88
84
  //#endregion
@@ -312,21 +308,86 @@ function isAsset(item) {
312
308
  return item.type === "asset";
313
309
  }
314
310
  //#endregion
311
+ //#region src/build/graph.ts
312
+ function importedChunkIds(chunk, includeDynamic = true) {
313
+ if (!includeDynamic) return [...chunk.imports];
314
+ return [...chunk.imports, ...chunk.dynamicImports ?? []];
315
+ }
316
+ function walkImportedChunks(chunk, bundle, options = {}) {
317
+ const includeDynamic = options.includeDynamic ?? true;
318
+ const result = [];
319
+ const seen = /* @__PURE__ */ new Set();
320
+ const visit = (current) => {
321
+ for (const fileName of importedChunkIds(current, includeDynamic)) {
322
+ if (seen.has(fileName)) continue;
323
+ const dep = bundle[fileName];
324
+ if (!dep || !isChunk(dep) || dep.isEntry) continue;
325
+ seen.add(fileName);
326
+ visit(dep);
327
+ result.push({
328
+ fileName,
329
+ chunk: dep
330
+ });
331
+ }
332
+ };
333
+ visit(chunk);
334
+ return result;
335
+ }
336
+ function collectImportedChunkIds(chunk, bundle) {
337
+ return new Set(walkImportedChunks(chunk, bundle).map((item) => item.fileName));
338
+ }
339
+ function inlineImportedChunks(chunk, bundle) {
340
+ return walkImportedChunks(chunk, bundle).map(({ chunk: dep }) => dep.code.endsWith("\n") ? dep.code : `${dep.code}\n`).join("");
341
+ }
342
+ function namespaceLiteral(chunk) {
343
+ return `{ ${(chunk.exports ?? []).map((name) => {
344
+ if (name === "default") return "default: undefined";
345
+ return /^[a-z_$][\w$]*$/i.test(name) ? name : `${JSON.stringify(name)}: undefined`;
346
+ }).join(", ")} }`;
347
+ }
348
+ function replaceDynamicImportCalls(code, fileName, namespace) {
349
+ const replacement = `Promise.resolve(${namespace})`;
350
+ const baseName = fileName.split("/").pop() ?? fileName;
351
+ const candidates = [
352
+ fileName,
353
+ `./${fileName}`,
354
+ baseName,
355
+ `./${baseName}`
356
+ ];
357
+ let next = code;
358
+ for (const name of new Set(candidates)) {
359
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
360
+ next = next.replace(new RegExp(`\\bimport\\s*\\(\\s*(?:/\\*[\\s\\S]*?\\*/\\s*)?(['"])${escaped}\\1\\s*\\)`, "g"), replacement);
361
+ }
362
+ return next;
363
+ }
364
+ function rewriteInlinedDynamicImports(code, chunk, bundle) {
365
+ let next = code;
366
+ const seen = /* @__PURE__ */ new Set();
367
+ const visit = (current) => {
368
+ for (const fileName of current.dynamicImports ?? []) {
369
+ if (seen.has(fileName)) continue;
370
+ seen.add(fileName);
371
+ const dep = bundle[fileName];
372
+ if (!dep || !isChunk(dep)) continue;
373
+ visit(dep);
374
+ next = replaceDynamicImportCalls(next, fileName, namespaceLiteral(dep));
375
+ }
376
+ };
377
+ visit(chunk);
378
+ if (next.includes("__vitePreload")) return `function __vitePreload(fn){return fn();}\n${next}`;
379
+ return next;
380
+ }
381
+ //#endregion
315
382
  //#region src/build/css.ts
316
383
  const defaultCssInjector = `(function (css) {
317
384
  var style = document.createElement('style')
318
385
  style.textContent = css
319
386
  ;(document.head || document.documentElement).appendChild(style)
320
387
  })`;
321
- function collectImportedCss(chunk, bundle, seen = /* @__PURE__ */ new Set()) {
388
+ function collectImportedCss(chunk, bundle) {
322
389
  const files = [...chunk.viteMetadata?.importedCss ?? []];
323
- for (const imported of chunk.imports) {
324
- if (seen.has(imported)) continue;
325
- const dep = bundle[imported];
326
- if (!dep || !isChunk(dep) || dep.isEntry) continue;
327
- seen.add(imported);
328
- files.push(...collectImportedCss(dep, bundle, seen));
329
- }
390
+ for (const { chunk: dep } of walkImportedChunks(chunk, bundle)) files.push(...dep.viteMetadata?.importedCss ?? []);
330
391
  return files;
331
392
  }
332
393
  function collectCss(chunk, bundle) {
@@ -347,6 +408,94 @@ function createCssInject(css) {
347
408
  return `${defaultCssInjector}(${JSON.stringify(css)});\n`;
348
409
  }
349
410
  //#endregion
411
+ //#region src/build/external.ts
412
+ function resolveExternals(external) {
413
+ if (!external) return [];
414
+ return Object.entries(external).map(([specifier, value]) => {
415
+ if (typeof value === "string") return {
416
+ specifier,
417
+ url: value,
418
+ global: toIdentifier(specifier)
419
+ };
420
+ return {
421
+ specifier,
422
+ url: value.url,
423
+ global: value.global
424
+ };
425
+ });
426
+ }
427
+ function toRequireList$1(value) {
428
+ if (value == null) return [];
429
+ return Array.isArray(value) ? value.map(String) : [String(value)];
430
+ }
431
+ function mergeRequireUrls(header, urls) {
432
+ if (!urls.length) return header;
433
+ const merged = [...toRequireList$1(header.require)];
434
+ for (const url of urls) if (!merged.includes(url)) merged.push(url);
435
+ return {
436
+ ...header,
437
+ require: merged
438
+ };
439
+ }
440
+ function withExternalRequires(header, externals) {
441
+ return mergeRequireUrls(header, externals.map((item) => item.url));
442
+ }
443
+ function normalizeNamedImports(inner) {
444
+ return inner.split(",").map((part) => {
445
+ const trimmed = part.trim();
446
+ if (!trimmed) return trimmed;
447
+ const asMatch = trimmed.match(/^([a-z_$][\w$]*)\s+as\s+([a-z_$][\w$]*)$/i);
448
+ if (asMatch) return `${asMatch[1]}: ${asMatch[2]}`;
449
+ return trimmed;
450
+ }).filter(Boolean).join(", ");
451
+ }
452
+ const IDENT = "([a-z_$][\\w$]*)";
453
+ const IDENT_FLAGS = "i";
454
+ function rewriteImportClause(clause, globalName) {
455
+ const trimmed = clause.trim();
456
+ const star = trimmed.match(new RegExp(`^\\*\\s+as\\s+${IDENT}$`, IDENT_FLAGS));
457
+ if (star?.[1]) return star[1] === globalName ? "" : `const ${star[1]} = ${globalName};`;
458
+ const defaultStar = trimmed.match(new RegExp(`^${IDENT}\\s*,\\s*\\*\\s+as\\s+${IDENT}$`, IDENT_FLAGS));
459
+ if (defaultStar?.[1] && defaultStar[2]) return [defaultStar[1] === globalName ? "" : `const ${defaultStar[1]} = ${globalName};`, defaultStar[2] === globalName ? "" : `const ${defaultStar[2]} = ${globalName};`].filter(Boolean).join(" ");
460
+ const defaultNamed = trimmed.match(new RegExp(`^${IDENT}\\s*,\\s*\\{([^}]+)\\}$`, IDENT_FLAGS));
461
+ if (defaultNamed?.[1] && defaultNamed[2] != null) {
462
+ const named = `const { ${normalizeNamedImports(defaultNamed[2])} } = ${globalName};`;
463
+ if (defaultNamed[1] === globalName) return named;
464
+ return `const ${defaultNamed[1]} = ${globalName}; ${named}`;
465
+ }
466
+ const named = trimmed.match(/^\{([^}]+)\}$/);
467
+ if (named?.[1] != null) return `const { ${normalizeNamedImports(named[1])} } = ${globalName};`;
468
+ if (/^[a-z_$][\w$]*$/i.test(trimmed)) return trimmed === globalName ? "" : `const ${trimmed} = ${globalName};`;
469
+ return `const ${trimmed} = ${globalName};`;
470
+ }
471
+ function rewriteExternalImports(code, externals) {
472
+ let next = code;
473
+ for (const { specifier, global: globalName } of externals) {
474
+ const quoted = `["']${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`;
475
+ next = next.replace(new RegExp(`(^|\\n)import\\s*${quoted}\\s*;?`, "g"), "$1");
476
+ next = next.replace(new RegExp(`(^|\\n)import\\s+type\\s+[\\s\\S]*?\\s+from\\s*${quoted}\\s*;?`, "g"), "$1");
477
+ next = next.replace(new RegExp(`(^|\\n)import\\s+([\\s\\S]*?)\\s+from\\s*${quoted}\\s*;?`, "g"), (_match, lead, clause) => {
478
+ const rewritten = rewriteImportClause(clause, globalName);
479
+ return rewritten ? `${lead}${rewritten}` : lead;
480
+ });
481
+ }
482
+ return next;
483
+ }
484
+ function mergeRolldownExternal(user, specifiers) {
485
+ if (!specifiers.length) return user;
486
+ if (typeof user === "function") {
487
+ const specSet = new Set(specifiers);
488
+ return (id, importer, isResolved) => {
489
+ if (specSet.has(id)) return true;
490
+ return user(id, importer, isResolved);
491
+ };
492
+ }
493
+ let list = [];
494
+ if (Array.isArray(user)) list = [...user];
495
+ else if (user != null) list = [user];
496
+ return [...list, ...specifiers];
497
+ }
498
+ //#endregion
350
499
  //#region src/build/iife.ts
351
500
  const SOURCE_MAPPING_URL_RE = /\/\/[#@]\s*sourceMappingURL=\S+/g;
352
501
  function stripSourceMappingUrl(code) {
@@ -358,11 +507,15 @@ function stripImports(code) {
358
507
  function stripExports(code) {
359
508
  return code.replace(/^export\s+\{[\s\S]*?\}\s+from\s+["'][^"']+["']\s*;?\s*$/gm, "").replace(/^export\s+\*\s+from\s+["'][^"']+["']\s*;?\s*$/gm, "").replace(/^export\s+\{[\s\S]*?\};?\s*$/gm, "").replace(/^export\s+default\s+/gm, "").replace(/^export\s+async\s+function/gm, "async function").replace(/^export\s+function/gm, "function").replace(/^export\s+class/gm, "class").replace(/^export\s+(const|let|var)/gm, "$1");
360
509
  }
510
+ function stripDynamicImports(code) {
511
+ return code.replace(/\bimport\s*\(\s*(?:\/\*[\s\S]*?\*\/\s*)?["'][^"']+["']\s*\)/g, "Promise.resolve({})");
512
+ }
361
513
  function stripModuleSyntax(code) {
362
- return stripExports(stripImports(code));
514
+ return stripExports(stripImports(stripDynamicImports(code)));
363
515
  }
364
516
  function isAlreadyIife(code) {
365
- return !/^\s*(?:import|export)\s/m.test(code) && /\(\s*(?:async\s+)?function\b/.test(code);
517
+ const trimmed = code.trimStart();
518
+ return !/^\s*(?:import|export)\s/m.test(code) && /^\(\s*(?:async\s+)?function\b/.test(trimmed);
366
519
  }
367
520
  function ensureIife(code) {
368
521
  const withoutMap = stripSourceMappingUrl(code);
@@ -380,7 +533,7 @@ function toRequireList(value) {
380
533
  return Array.isArray(value) ? value.map(String) : [String(value)];
381
534
  }
382
535
  function createWatchProxyHeader(script, jsAbsPath) {
383
- const header = withServeGrants({ ...script.header });
536
+ const header = withServeGrants(withExternalRequires(script.header, script.external));
384
537
  return {
385
538
  ...header,
386
539
  require: [...toRequireList(header.require), toFileRequireUrl(jsAbsPath)]
@@ -403,35 +556,6 @@ function toRequireFileName(fileName) {
403
556
  }
404
557
  //#endregion
405
558
  //#region src/build/apply.ts
406
- function importedChunkIds(chunk) {
407
- return [...chunk.imports, ...chunk.dynamicImports ?? []];
408
- }
409
- function inlineImportedChunks(chunk, bundle, seen = /* @__PURE__ */ new Set()) {
410
- let prelude = "";
411
- for (const imported of chunk.imports) {
412
- if (seen.has(imported)) continue;
413
- const dep = bundle[imported];
414
- if (!dep || !isChunk(dep) || dep.isEntry) continue;
415
- seen.add(imported);
416
- prelude += inlineImportedChunks(dep, bundle, seen);
417
- prelude += dep.code.endsWith("\n") ? dep.code : `${dep.code}\n`;
418
- }
419
- return prelude;
420
- }
421
- function collectImportedChunks(chunk, bundle) {
422
- const files = /* @__PURE__ */ new Set();
423
- const walk = (current) => {
424
- for (const imported of importedChunkIds(current)) {
425
- if (files.has(imported)) continue;
426
- const dep = bundle[imported];
427
- if (!dep || !isChunk(dep) || dep.isEntry) continue;
428
- files.add(imported);
429
- walk(dep);
430
- }
431
- };
432
- walk(chunk);
433
- return files;
434
- }
435
559
  function collectImportedCssFiles(fileNames, bundle) {
436
560
  const files = /* @__PURE__ */ new Set();
437
561
  for (const fileName of fileNames) {
@@ -449,8 +573,13 @@ function withSourceMaps(fileNames) {
449
573
  function findScriptForChunk(chunk, fileName, scripts) {
450
574
  return scripts.find((script) => chunk.name === script.fileName || fileName === `${script.fileName}.js` || fileName === `${script.fileName}.user.js`);
451
575
  }
576
+ function offsetUserscriptMap(options) {
577
+ const wrapOffset = isAlreadyIife(stripSourceMappingUrl(options.body)) ? 0 : 1;
578
+ const lineOffset = countHeaderLines(options.headerPrefix ?? "") + countHeaderLines(options.cssPrelude) + wrapOffset + countHeaderLines(options.inlined);
579
+ return stripVendorSourcesContent(offsetSourceMap(options.map, lineOffset, options.fileName));
580
+ }
452
581
  function createHeadedUserscript(script, options) {
453
- const headerConfig = withBuildGrants(script.header, options.wrapped);
582
+ const headerConfig = withBuildGrants(withExternalRequires(script.header, script.external), options.wrapped);
454
583
  const prefix = `${generateHeader(headerConfig, {
455
584
  align: script.headerAlign,
456
585
  autoMetaUrls: script.autoMetaUrls,
@@ -461,9 +590,14 @@ function createHeadedUserscript(script, options) {
461
590
  const nextFileName = `${script.fileName}.user.js`;
462
591
  let nextCode = `${prefix}${options.code}`;
463
592
  if (options.map) {
464
- const wrapOffset = isAlreadyIife(stripSourceMappingUrl(options.body)) ? 0 : 1;
465
- const lineOffset = countHeaderLines(prefix) + countHeaderLines(options.cssPrelude) + wrapOffset + countHeaderLines(options.inlined);
466
- const map = stripVendorSourcesContent(offsetSourceMap(options.map, lineOffset, nextFileName));
593
+ const map = offsetUserscriptMap({
594
+ map: options.map,
595
+ fileName: nextFileName,
596
+ cssPrelude: options.cssPrelude,
597
+ inlined: options.inlined,
598
+ body: options.body,
599
+ headerPrefix: prefix
600
+ });
467
601
  nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(map)}\n`;
468
602
  }
469
603
  return {
@@ -491,7 +625,7 @@ function applyUserscriptBundle(bundle, config, context) {
491
625
  const keptChunks = new Set(otherEntryFiles);
492
626
  for (const fileName of otherEntryFiles) {
493
627
  const chunk = bundle[fileName];
494
- if (chunk && isChunk(chunk)) for (const dep of collectImportedChunks(chunk, bundle)) keptChunks.add(dep);
628
+ if (chunk && isChunk(chunk)) for (const dep of collectImportedChunkIds(chunk, bundle)) keptChunks.add(dep);
495
629
  }
496
630
  const keptCss = collectImportedCssFiles(keptChunks, bundle);
497
631
  const leftoverChunks = [];
@@ -501,7 +635,7 @@ function applyUserscriptBundle(bundle, config, context) {
501
635
  const { css, files: cssFiles } = collectCss(chunk, bundle);
502
636
  for (const cssFile of cssFiles) if (!keptCss.has(cssFile)) leftoverAssets.push(...withSourceMaps([cssFile]));
503
637
  const cssPrelude = css ? createCssInject(css) : "";
504
- const body = `${inlined}${chunk.code}`;
638
+ const body = rewriteExternalImports(rewriteInlinedDynamicImports(`${inlined}${chunk.code}`, chunk, bundle), script.external);
505
639
  const wrapped = ensureIife(body);
506
640
  const code = `${cssPrelude}${wrapped}`;
507
641
  const emitFileProxy = Boolean(context.emitProxy && context.outDir && script.server.file);
@@ -518,9 +652,13 @@ function applyUserscriptBundle(bundle, config, context) {
518
652
  const requireName = toRequireFileName(script.fileName);
519
653
  let nextCode = code.endsWith("\n") ? code : `${code}\n`;
520
654
  if (chunk.map) {
521
- const wrapOffset = isAlreadyIife(stripSourceMappingUrl(body)) ? 0 : 1;
522
- const lineOffset = countHeaderLines(cssPrelude) + wrapOffset + countHeaderLines(inlined);
523
- chunk.map = stripVendorSourcesContent(offsetSourceMap(chunk.map, lineOffset, requireName));
655
+ chunk.map = offsetUserscriptMap({
656
+ map: chunk.map,
657
+ fileName: requireName,
658
+ cssPrelude,
659
+ inlined,
660
+ body
661
+ });
524
662
  nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(chunk.map)}\n`;
525
663
  }
526
664
  chunk.code = nextCode;
@@ -652,7 +790,6 @@ function toResolvedScript(config) {
652
790
  return {
653
791
  entry: config.entry,
654
792
  fileName,
655
- iifeName: toIdentifier(fileName),
656
793
  header: config.header,
657
794
  server: {
658
795
  open: resolveServerOpen(config.server?.open, file),
@@ -662,7 +799,8 @@ function toResolvedScript(config) {
662
799
  headerAlign: config.headerAlign ?? 1,
663
800
  generate: config.generate,
664
801
  autoMetaUrls: config.autoMetaUrls ?? false,
665
- metaFile: config.metaFile ?? true
802
+ metaFile: config.metaFile ?? true,
803
+ external: resolveExternals(config.external)
666
804
  };
667
805
  }
668
806
  function collectAutoMetaUrlsWarnings(config) {
@@ -714,6 +852,28 @@ function resolvePluginConfig(config) {
714
852
  return { scripts };
715
853
  }
716
854
  //#endregion
855
+ //#region src/serve/external.ts
856
+ const EXTERNAL_MODULE_PREFIX = `\0${PLUGIN_NAME}:external:`;
857
+ function toExternalModuleId(specifier) {
858
+ return `${EXTERNAL_MODULE_PREFIX}${specifier}`;
859
+ }
860
+ function matchExternalModuleId(id) {
861
+ if (!id.startsWith(EXTERNAL_MODULE_PREFIX)) return;
862
+ return id.slice(EXTERNAL_MODULE_PREFIX.length);
863
+ }
864
+ function findResolvedExternal(scripts, specifier) {
865
+ for (const script of scripts) {
866
+ const hit = script.external.find((item) => item.specifier === specifier);
867
+ if (hit) return hit;
868
+ }
869
+ }
870
+ function listExternalSpecifiers(scripts) {
871
+ return [...new Set(scripts.flatMap((script) => script.external.map((item) => item.specifier)))];
872
+ }
873
+ function renderExternalModule(globalName) {
874
+ return `const api = globalThis[${JSON.stringify(globalName)}];\nexport default api;\n`;
875
+ }
876
+ //#endregion
717
877
  //#region src/serve/gm-shim.ts
718
878
  function shouldShimModule(id) {
719
879
  const cleanId = id.split("\0").pop() ?? id;
@@ -813,14 +973,17 @@ import ${JSON.stringify(entryPath)};
813
973
  }
814
974
  //#endregion
815
975
  //#region src/serve/wrapper.ts
976
+ function matchUserscriptPath(url, fileName, suffix) {
977
+ return (url.split("?")[0] ?? "") === `/${fileName}${suffix}`;
978
+ }
816
979
  function matchDevUserscript(url, fileName) {
817
- return (url.split("?")[0] ?? "") === `/${fileName}.dev.user.js`;
980
+ return matchUserscriptPath(url, fileName, ".dev.user.js");
818
981
  }
819
982
  function matchProxyUserscript(url, fileName) {
820
- return (url.split("?")[0] ?? "") === `/${toProxyFileName(fileName)}`;
983
+ return matchUserscriptPath(url, fileName, ".proxy.user.js");
821
984
  }
822
985
  function matchFileUserscript(url, fileName) {
823
- return (url.split("?")[0] ?? "") === `/${fileName}.user.js`;
986
+ return matchUserscriptPath(url, fileName, ".user.js");
824
987
  }
825
988
  function toInstallFileName(fileName, kind) {
826
989
  return {
@@ -839,17 +1002,17 @@ function toServeEntryPath(root, entry) {
839
1002
  const absolute = resolve(root, entry);
840
1003
  return `/${relative(root, absolute).split(sep).join(posix.sep)}`;
841
1004
  }
842
- function applyServeHeader(header, prefix) {
843
- return withServeGrants({
1005
+ function applyServeHeader(header, prefix, externals = []) {
1006
+ return withServeGrants(withExternalRequires({
844
1007
  ...header,
845
1008
  name: prefix === false ? header.name : `${prefix}${header.name}`
846
- });
1009
+ }, externals));
847
1010
  }
848
1011
  function generateDevWrapper(options) {
849
1012
  const clientUrl = `${options.origin}/@vite/client`;
850
1013
  const entryUrl = `${options.origin}${options.entryPath}`;
851
1014
  const bootstrapUrl = `${options.origin}${REACT_BOOTSTRAP_PATH}?entry=${encodeURIComponent(options.entryPath)}`;
852
- const copies = gmIdentifiers.map((id) => `if (typeof ${id} !== 'undefined') gm.${id} = ${id};`).join("\n ");
1015
+ const copies = [...gmIdentifiers.map((id) => `if (typeof ${id} !== 'undefined') gm.${id} = ${id};`), ...(options.externals ?? []).map((item) => `if (typeof ${item.global} !== 'undefined') root.${item.global} = ${item.global};`)].join("\n ");
853
1016
  const injectTarget = options.reactPreamble ? bootstrapUrl : entryUrl;
854
1017
  const clientInject = options.reactPreamble ? "" : `
855
1018
  if (!root.${VITE_CLIENT_FLAG}) {
@@ -874,14 +1037,15 @@ ${clientInject}
874
1037
  `;
875
1038
  }
876
1039
  function generateDevUserscript(options) {
877
- return `${generateHeader(applyServeHeader(options.script.header, options.prefix), {
1040
+ return `${generateHeader(applyServeHeader(options.script.header, options.prefix, options.script.external), {
878
1041
  ...options.headerOptions,
879
1042
  fileName: options.script.fileName,
880
1043
  mode: "serve"
881
1044
  })}\n\n${generateDevWrapper({
882
1045
  origin: options.origin,
883
1046
  entryPath: toServeEntryPath(options.root, options.script.entry),
884
- reactPreamble: options.reactPreamble
1047
+ reactPreamble: options.reactPreamble,
1048
+ externals: options.script.external
885
1049
  })}`;
886
1050
  }
887
1051
  function findDevScript(url, scripts) {
@@ -995,6 +1159,49 @@ function configureDevServer(server, resolved, reactPreamble) {
995
1159
  };
996
1160
  }
997
1161
  //#endregion
1162
+ //#region src/serve/watch-queue.ts
1163
+ function createDebouncedSingleFlight(task, delay, onError) {
1164
+ let timer;
1165
+ let running = false;
1166
+ let queued = false;
1167
+ const run = async () => {
1168
+ if (running) {
1169
+ queued = true;
1170
+ return;
1171
+ }
1172
+ running = true;
1173
+ try {
1174
+ while (true) {
1175
+ queued = false;
1176
+ await task();
1177
+ if (!queued) break;
1178
+ }
1179
+ } catch (error) {
1180
+ onError(error);
1181
+ } finally {
1182
+ running = false;
1183
+ if (queued) {
1184
+ queued = false;
1185
+ run();
1186
+ }
1187
+ }
1188
+ };
1189
+ return {
1190
+ schedule: () => {
1191
+ clearTimeout(timer);
1192
+ timer = setTimeout(() => {
1193
+ timer = void 0;
1194
+ run();
1195
+ }, delay);
1196
+ },
1197
+ cancel: () => {
1198
+ clearTimeout(timer);
1199
+ timer = void 0;
1200
+ },
1201
+ pending: () => running || queued || timer != null
1202
+ };
1203
+ }
1204
+ //#endregion
998
1205
  //#region src/plugin.ts
999
1206
  function absolutizeEntries(config, root) {
1000
1207
  return { scripts: config.scripts.map((script) => ({
@@ -1048,30 +1255,34 @@ function UserscriptPlugin(config) {
1048
1255
  server.config.logger.error(String(error));
1049
1256
  return;
1050
1257
  }
1051
- let timer;
1258
+ const scheduler = createDebouncedSingleFlight(run, 80, (error) => {
1259
+ server.config.logger.error(String(error));
1260
+ });
1052
1261
  const onChange = (file) => {
1053
1262
  if (file.startsWith(outDirAbs)) return;
1054
- clearTimeout(timer);
1055
- timer = setTimeout(() => {
1056
- run().catch((error) => {
1057
- server.config.logger.error(String(error));
1058
- });
1059
- }, 80);
1263
+ scheduler.schedule();
1060
1264
  };
1061
1265
  server.watcher.on("change", onChange);
1062
1266
  server.watcher.on("add", onChange);
1063
- const closeServer = server.close.bind(server);
1064
- server.close = async () => {
1267
+ let cleaned = false;
1268
+ const cleanup = () => {
1269
+ if (cleaned) return;
1270
+ cleaned = true;
1271
+ scheduler.cancel();
1065
1272
  server.watcher.off("change", onChange);
1066
1273
  server.watcher.off("add", onChange);
1067
- clearTimeout(timer);
1274
+ };
1275
+ server.httpServer?.once("close", cleanup);
1276
+ const closeServer = server.close.bind(server);
1277
+ server.close = async () => {
1278
+ cleanup();
1068
1279
  return closeServer();
1069
1280
  };
1070
1281
  };
1071
1282
  return [
1072
1283
  {
1073
1284
  name: `${PLUGIN_NAME}:config`,
1074
- config(userConfig) {
1285
+ config(userConfig, env) {
1075
1286
  const { input, hasHtml } = resolvePluginBuildInput(userConfig, resolved.scripts);
1076
1287
  const scriptNames = new Set(resolved.scripts.map((script) => script.fileName));
1077
1288
  userConfig.build ??= {};
@@ -1081,11 +1292,25 @@ function UserscriptPlugin(config) {
1081
1292
  const userEntryFileNames = userOutput && !Array.isArray(userOutput) ? userOutput.entryFileNames : void 0;
1082
1293
  const openScript = resolved.scripts.find((script) => script.server.open);
1083
1294
  const openPath = openScript?.server.open ? toInstallPath(openScript.fileName, openScript.server.open) : void 0;
1295
+ const specifiers = listExternalSpecifiers(resolved.scripts);
1296
+ const rolldownOptions = {
1297
+ input,
1298
+ output: {
1299
+ format: "es",
1300
+ entryFileNames: (chunkInfo) => {
1301
+ if (scriptNames.has(chunkInfo.name)) return "[name].js";
1302
+ if (typeof userEntryFileNames === "function") return userEntryFileNames(chunkInfo);
1303
+ if (typeof userEntryFileNames === "string") return userEntryFileNames;
1304
+ return "assets/[name]-[hash].js";
1305
+ }
1306
+ }
1307
+ };
1308
+ if (env.command === "build" && specifiers.length) rolldownOptions.external = mergeRolldownExternal(userConfig.build.rolldownOptions.external, specifiers);
1084
1309
  return {
1085
1310
  appType: userConfig.appType ?? (hasHtml ? "spa" : "custom"),
1086
1311
  optimizeDeps: {
1087
1312
  entries: Object.values(input),
1088
- exclude: [VIRTUAL_MODULE_ID]
1313
+ exclude: [VIRTUAL_MODULE_ID, ...specifiers]
1089
1314
  },
1090
1315
  server: {
1091
1316
  cors: userConfig.server?.cors ?? true,
@@ -1094,18 +1319,7 @@ function UserscriptPlugin(config) {
1094
1319
  build: {
1095
1320
  minify: userConfig.build?.minify ?? false,
1096
1321
  assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? Number.MAX_SAFE_INTEGER,
1097
- rolldownOptions: {
1098
- input,
1099
- output: {
1100
- format: "es",
1101
- entryFileNames: (chunkInfo) => {
1102
- if (scriptNames.has(chunkInfo.name)) return "[name].js";
1103
- if (typeof userEntryFileNames === "function") return userEntryFileNames(chunkInfo);
1104
- if (typeof userEntryFileNames === "string") return userEntryFileNames;
1105
- return "assets/[name]-[hash].js";
1106
- }
1107
- }
1108
- }
1322
+ rolldownOptions
1109
1323
  }
1110
1324
  };
1111
1325
  },
@@ -1120,9 +1334,15 @@ function UserscriptPlugin(config) {
1120
1334
  name: `${PLUGIN_NAME}:virtual`,
1121
1335
  resolveId: (id) => {
1122
1336
  if (id === "virtual:vite-userscript-plugin") return RESOLVED_VIRTUAL_MODULE_ID;
1337
+ if (command === "serve" && findResolvedExternal(resolved.scripts, id)) return toExternalModuleId(id);
1123
1338
  },
1124
1339
  load: (id) => {
1125
1340
  if (id === RESOLVED_VIRTUAL_MODULE_ID) return renderVirtualModule(createClientSnapshot(resolved.scripts, command));
1341
+ const specifier = matchExternalModuleId(id);
1342
+ if (!specifier) return;
1343
+ const external = findResolvedExternal(resolved.scripts, specifier);
1344
+ if (!external) return;
1345
+ return renderExternalModule(external.global);
1126
1346
  }
1127
1347
  },
1128
1348
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vite-userscript-plugin",
3
3
  "type": "module",
4
- "version": "2.3.1",
4
+ "version": "2.4.0",
5
5
  "author": {
6
6
  "name": "Vitalij Ryndin",
7
7
  "url": "https://github.com/crashmax-dev"
@@ -52,7 +52,8 @@
52
52
  "eslint-plugin-format": "2.0.1",
53
53
  "prettier-plugin-css-order": "2.2.0",
54
54
  "svelte": "5.57.0",
55
- "tsdown": "0.22.14",
55
+ "taze": "21.1.0",
56
+ "tsdown": "0.23.0",
56
57
  "turbo": "2.10.12",
57
58
  "typescript": "npm:@typescript/typescript6@6.0.2",
58
59
  "vite": "8.2.2",
@@ -66,7 +67,8 @@
66
67
  "build:examples": "turbo run build --filter=./examples/*",
67
68
  "test": "vitest",
68
69
  "test:ui": "vitest --ui --watch",
69
- "type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p scripts/tsconfig.json && turbo run type-check --filter=./examples/*",
70
+ "type-check": "tsc --noEmit -p tsconfig.json && turbo run type-check --filter=./examples/*",
71
+ "check-update": "taze major -Irl",
70
72
  "sync-types": "node scripts/sync-types.ts",
71
73
  "lint": "eslint . --cache",
72
74
  "lint:fix": "eslint . --fix --cache"