vite-userscript-plugin 2.3.0 → 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
 
@@ -133,7 +135,7 @@ See [examples/sourcemap](./examples/sourcemap).
133
135
  | Option | Default | Description |
134
136
  | --- | --- | --- |
135
137
  | `entry` | — | Userscript entry. Required. |
136
- | `header` | — | Metablock. Required: `name`, `version`, `match`. Relative `icon` / `require` / `resource` / `supportURL` / `updateURL` / `downloadURL` join `homepage` (`homepageURL` / `website` / `source`). Absolute `http(s):` URLs stay as-is. |
138
+ | `header` | — | Metablock. Required: `name`, `version`, `match`. Relative `icon` / `require` / `resource` / `supportURL` / `updateURL` / `downloadURL` join `homepage` (`homepageURL` / `website` / `source`). Absolute `http(s):` URLs stay as-is. `updateURL` / `downloadURL` of `none` disable updates and are not joined. |
137
139
  | `fileName` | sanitized `header.name` | Output base name (`{fileName}.user.js`). |
138
140
  | `server.open` | `false` | Open the install target when Vite starts (`true`, `'user'`, `'proxy'`). HMR: `.dev.user.js`. `file`: `.user.js` or `.proxy.user.js`. Vite opens one URL. |
139
141
  | `server.prefix` | `'server:'` | Prefix for `@name` in serve mode. `false` disables it. |
@@ -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
@@ -63,18 +63,33 @@ type HeaderConfig = {
63
63
  */
64
64
  'author'?: string;
65
65
  /**
66
+ * Script homepage. Also the base for relative `icon`, `iconURL`,
67
+ * `defaulticon`, `icon64`, `icon64URL`, `require`, `resource`, `supportURL`,
68
+ * `updateURL`, and `downloadURL`. Absolute `http(s):` URLs stay as-is.
69
+ * `updateURL` / `downloadURL` of `none` are not joined.
70
+ *
71
+ * Fallback order: `homepage`, `homepageURL`, `website`, `source`.
72
+ *
66
73
  * @see https://www.tampermonkey.net/documentation.php#meta:homepage
67
74
  */
68
75
  'homepage'?: string;
69
76
  /**
77
+ * Alias of `homepage`. Used as the relative-URL base when `homepage` is empty.
78
+ *
70
79
  * @see https://www.tampermonkey.net/documentation.php#meta:homepage
71
80
  */
72
81
  'homepageURL'?: string;
73
82
  /**
83
+ * Alias of `homepage`. Used as the relative-URL base when `homepage` and
84
+ * `homepageURL` are empty.
85
+ *
74
86
  * @see https://www.tampermonkey.net/documentation.php#meta:homepage
75
87
  */
76
88
  'website'?: string;
77
89
  /**
90
+ * Alias of `homepage`. Used as the relative-URL base when `homepage`,
91
+ * `homepageURL`, and `website` are empty.
92
+ *
78
93
  * @see https://www.tampermonkey.net/documentation.php#meta:homepage
79
94
  */
80
95
  'source'?: string;
@@ -124,13 +139,17 @@ type HeaderConfig = {
124
139
  */
125
140
  'noframes'?: boolean;
126
141
  /**
142
+ * `none` disables update checks. It is not joined with homepage.
143
+ *
127
144
  * @see https://www.tampermonkey.net/documentation.php#meta:updateURL
128
145
  */
129
- 'updateURL'?: string;
146
+ 'updateURL'?: 'none' | (string & {});
130
147
  /**
148
+ * `none` disables update checks (pinned / non-latest installs). It is not joined with homepage.
149
+ *
131
150
  * @see https://www.tampermonkey.net/documentation.php#meta:downloadURL
132
151
  */
133
- 'downloadURL'?: string;
152
+ 'downloadURL'?: 'none' | (string & {});
134
153
  /**
135
154
  * @see https://www.tampermonkey.net/documentation.php#meta:supportURL
136
155
  */
@@ -176,6 +195,22 @@ interface HeaderGenerateContext {
176
195
  userscript: string;
177
196
  mode: HeaderMode;
178
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
+ }
179
214
  /**
180
215
  * One userscript. Pass an object, or an array of these, to {@link UserscriptPluginConfig}.
181
216
  */
@@ -224,12 +259,21 @@ interface UserscriptConfig {
224
259
  * @default true
225
260
  */
226
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;
227
272
  }
228
273
  type UserscriptPluginConfig = UserscriptConfig | UserscriptConfig[];
229
274
  interface ResolvedScript {
230
275
  entry: string;
231
276
  fileName: string;
232
- iifeName: string;
233
277
  header: HeaderConfig;
234
278
  server: {
235
279
  open: ResolvedServerOpen;
@@ -240,9 +284,10 @@ interface ResolvedScript {
240
284
  generate?: (ctx: HeaderGenerateContext) => string;
241
285
  autoMetaUrls: boolean;
242
286
  metaFile: boolean;
287
+ external: ResolvedExternal[];
243
288
  }
244
289
  //#endregion
245
290
  //#region src/plugin.d.ts
246
291
  declare function UserscriptPlugin(config: UserscriptPluginConfig): Plugin[];
247
292
  //#endregion
248
- 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
@@ -118,16 +114,17 @@ function resolveHomePage(header) {
118
114
  const trimmed = homePage.trim();
119
115
  return trimmed === "" ? void 0 : trimmed;
120
116
  }
121
- function isResolvableHeaderPath(value) {
117
+ function isResolvableHeaderPath(value, field) {
122
118
  const trimmed = value.trim();
123
119
  if (!trimmed) return false;
120
+ if ((field === "updateURL" || field === "downloadURL") && trimmed.toLowerCase() === "none") return false;
124
121
  if (ABSOLUTE_URL_RE.test(trimmed) || trimmed.startsWith("//") || trimmed.startsWith("/")) return false;
125
122
  return true;
126
123
  }
127
- function containsResolvableHeaderPath(value) {
128
- if (typeof value === "string") return isResolvableHeaderPath(value);
124
+ function containsResolvableHeaderPath(value, field) {
125
+ if (typeof value === "string") return isResolvableHeaderPath(value, field);
129
126
  if (!Array.isArray(value)) return false;
130
- return value.some((item) => typeof item === "string" && isResolvableHeaderPath(item));
127
+ return value.some((item) => typeof item === "string" && isResolvableHeaderPath(item, field));
131
128
  }
132
129
  function containsResolvableResourcePath(resource) {
133
130
  if (!Array.isArray(resource)) return false;
@@ -137,7 +134,7 @@ function containsResolvableResourcePath(resource) {
137
134
  }
138
135
  function listHomepageRelativeFields(header) {
139
136
  const fields = [];
140
- for (const key of HOMEPAGE_RELATIVE_FIELDS) if (containsResolvableHeaderPath(header[key])) fields.push(key);
137
+ for (const key of HOMEPAGE_RELATIVE_FIELDS) if (containsResolvableHeaderPath(header[key], key)) fields.push(key);
141
138
  if (containsResolvableResourcePath(header.resource)) fields.push("resource");
142
139
  return fields;
143
140
  }
@@ -148,12 +145,12 @@ function resolvePublicFileUrl(header, fileName) {
148
145
  return new URL(fileName, ensureTrailingSlash(homePage)).href;
149
146
  } catch {}
150
147
  }
151
- function resolveHeaderUrlValue(header, value) {
148
+ function resolveHeaderUrlValue(header, value, field) {
152
149
  if (typeof value === "string") {
153
- if (!isResolvableHeaderPath(value)) return value;
150
+ if (!isResolvableHeaderPath(value, field)) return value;
154
151
  return resolvePublicFileUrl(header, value.trim()) ?? value;
155
152
  }
156
- if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? resolveHeaderUrlValue(header, item) : item);
153
+ if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? resolveHeaderUrlValue(header, item, field) : item);
157
154
  return value;
158
155
  }
159
156
  function resolveResourceUrls(header, resource) {
@@ -169,7 +166,7 @@ function resolveResourceUrls(header, resource) {
169
166
  function applyHomepageRelativeUrls(header) {
170
167
  if (!resolveHomePage(header)) return header;
171
168
  const next = { ...header };
172
- for (const key of HOMEPAGE_RELATIVE_FIELDS) if (next[key] != null) Object.assign(next, { [key]: resolveHeaderUrlValue(next, next[key]) });
169
+ for (const key of HOMEPAGE_RELATIVE_FIELDS) if (next[key] != null) Object.assign(next, { [key]: resolveHeaderUrlValue(next, next[key], key) });
173
170
  if (next.resource != null) next.resource = resolveResourceUrls(next, next.resource);
174
171
  return next;
175
172
  }
@@ -311,21 +308,86 @@ function isAsset(item) {
311
308
  return item.type === "asset";
312
309
  }
313
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
314
382
  //#region src/build/css.ts
315
383
  const defaultCssInjector = `(function (css) {
316
384
  var style = document.createElement('style')
317
385
  style.textContent = css
318
386
  ;(document.head || document.documentElement).appendChild(style)
319
387
  })`;
320
- function collectImportedCss(chunk, bundle, seen = /* @__PURE__ */ new Set()) {
388
+ function collectImportedCss(chunk, bundle) {
321
389
  const files = [...chunk.viteMetadata?.importedCss ?? []];
322
- for (const imported of chunk.imports) {
323
- if (seen.has(imported)) continue;
324
- const dep = bundle[imported];
325
- if (!dep || !isChunk(dep) || dep.isEntry) continue;
326
- seen.add(imported);
327
- files.push(...collectImportedCss(dep, bundle, seen));
328
- }
390
+ for (const { chunk: dep } of walkImportedChunks(chunk, bundle)) files.push(...dep.viteMetadata?.importedCss ?? []);
329
391
  return files;
330
392
  }
331
393
  function collectCss(chunk, bundle) {
@@ -346,6 +408,94 @@ function createCssInject(css) {
346
408
  return `${defaultCssInjector}(${JSON.stringify(css)});\n`;
347
409
  }
348
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
349
499
  //#region src/build/iife.ts
350
500
  const SOURCE_MAPPING_URL_RE = /\/\/[#@]\s*sourceMappingURL=\S+/g;
351
501
  function stripSourceMappingUrl(code) {
@@ -357,11 +507,15 @@ function stripImports(code) {
357
507
  function stripExports(code) {
358
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");
359
509
  }
510
+ function stripDynamicImports(code) {
511
+ return code.replace(/\bimport\s*\(\s*(?:\/\*[\s\S]*?\*\/\s*)?["'][^"']+["']\s*\)/g, "Promise.resolve({})");
512
+ }
360
513
  function stripModuleSyntax(code) {
361
- return stripExports(stripImports(code));
514
+ return stripExports(stripImports(stripDynamicImports(code)));
362
515
  }
363
516
  function isAlreadyIife(code) {
364
- 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);
365
519
  }
366
520
  function ensureIife(code) {
367
521
  const withoutMap = stripSourceMappingUrl(code);
@@ -379,7 +533,7 @@ function toRequireList(value) {
379
533
  return Array.isArray(value) ? value.map(String) : [String(value)];
380
534
  }
381
535
  function createWatchProxyHeader(script, jsAbsPath) {
382
- const header = withServeGrants({ ...script.header });
536
+ const header = withServeGrants(withExternalRequires(script.header, script.external));
383
537
  return {
384
538
  ...header,
385
539
  require: [...toRequireList(header.require), toFileRequireUrl(jsAbsPath)]
@@ -402,35 +556,6 @@ function toRequireFileName(fileName) {
402
556
  }
403
557
  //#endregion
404
558
  //#region src/build/apply.ts
405
- function importedChunkIds(chunk) {
406
- return [...chunk.imports, ...chunk.dynamicImports ?? []];
407
- }
408
- function inlineImportedChunks(chunk, bundle, seen = /* @__PURE__ */ new Set()) {
409
- let prelude = "";
410
- for (const imported of chunk.imports) {
411
- if (seen.has(imported)) continue;
412
- const dep = bundle[imported];
413
- if (!dep || !isChunk(dep) || dep.isEntry) continue;
414
- seen.add(imported);
415
- prelude += inlineImportedChunks(dep, bundle, seen);
416
- prelude += dep.code.endsWith("\n") ? dep.code : `${dep.code}\n`;
417
- }
418
- return prelude;
419
- }
420
- function collectImportedChunks(chunk, bundle) {
421
- const files = /* @__PURE__ */ new Set();
422
- const walk = (current) => {
423
- for (const imported of importedChunkIds(current)) {
424
- if (files.has(imported)) continue;
425
- const dep = bundle[imported];
426
- if (!dep || !isChunk(dep) || dep.isEntry) continue;
427
- files.add(imported);
428
- walk(dep);
429
- }
430
- };
431
- walk(chunk);
432
- return files;
433
- }
434
559
  function collectImportedCssFiles(fileNames, bundle) {
435
560
  const files = /* @__PURE__ */ new Set();
436
561
  for (const fileName of fileNames) {
@@ -448,8 +573,13 @@ function withSourceMaps(fileNames) {
448
573
  function findScriptForChunk(chunk, fileName, scripts) {
449
574
  return scripts.find((script) => chunk.name === script.fileName || fileName === `${script.fileName}.js` || fileName === `${script.fileName}.user.js`);
450
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
+ }
451
581
  function createHeadedUserscript(script, options) {
452
- const headerConfig = withBuildGrants(script.header, options.wrapped);
582
+ const headerConfig = withBuildGrants(withExternalRequires(script.header, script.external), options.wrapped);
453
583
  const prefix = `${generateHeader(headerConfig, {
454
584
  align: script.headerAlign,
455
585
  autoMetaUrls: script.autoMetaUrls,
@@ -460,9 +590,14 @@ function createHeadedUserscript(script, options) {
460
590
  const nextFileName = `${script.fileName}.user.js`;
461
591
  let nextCode = `${prefix}${options.code}`;
462
592
  if (options.map) {
463
- const wrapOffset = isAlreadyIife(stripSourceMappingUrl(options.body)) ? 0 : 1;
464
- const lineOffset = countHeaderLines(prefix) + countHeaderLines(options.cssPrelude) + wrapOffset + countHeaderLines(options.inlined);
465
- 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
+ });
466
601
  nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(map)}\n`;
467
602
  }
468
603
  return {
@@ -490,7 +625,7 @@ function applyUserscriptBundle(bundle, config, context) {
490
625
  const keptChunks = new Set(otherEntryFiles);
491
626
  for (const fileName of otherEntryFiles) {
492
627
  const chunk = bundle[fileName];
493
- 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);
494
629
  }
495
630
  const keptCss = collectImportedCssFiles(keptChunks, bundle);
496
631
  const leftoverChunks = [];
@@ -500,7 +635,7 @@ function applyUserscriptBundle(bundle, config, context) {
500
635
  const { css, files: cssFiles } = collectCss(chunk, bundle);
501
636
  for (const cssFile of cssFiles) if (!keptCss.has(cssFile)) leftoverAssets.push(...withSourceMaps([cssFile]));
502
637
  const cssPrelude = css ? createCssInject(css) : "";
503
- const body = `${inlined}${chunk.code}`;
638
+ const body = rewriteExternalImports(rewriteInlinedDynamicImports(`${inlined}${chunk.code}`, chunk, bundle), script.external);
504
639
  const wrapped = ensureIife(body);
505
640
  const code = `${cssPrelude}${wrapped}`;
506
641
  const emitFileProxy = Boolean(context.emitProxy && context.outDir && script.server.file);
@@ -517,9 +652,13 @@ function applyUserscriptBundle(bundle, config, context) {
517
652
  const requireName = toRequireFileName(script.fileName);
518
653
  let nextCode = code.endsWith("\n") ? code : `${code}\n`;
519
654
  if (chunk.map) {
520
- const wrapOffset = isAlreadyIife(stripSourceMappingUrl(body)) ? 0 : 1;
521
- const lineOffset = countHeaderLines(cssPrelude) + wrapOffset + countHeaderLines(inlined);
522
- 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
+ });
523
662
  nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(chunk.map)}\n`;
524
663
  }
525
664
  chunk.code = nextCode;
@@ -651,7 +790,6 @@ function toResolvedScript(config) {
651
790
  return {
652
791
  entry: config.entry,
653
792
  fileName,
654
- iifeName: toIdentifier(fileName),
655
793
  header: config.header,
656
794
  server: {
657
795
  open: resolveServerOpen(config.server?.open, file),
@@ -661,7 +799,8 @@ function toResolvedScript(config) {
661
799
  headerAlign: config.headerAlign ?? 1,
662
800
  generate: config.generate,
663
801
  autoMetaUrls: config.autoMetaUrls ?? false,
664
- metaFile: config.metaFile ?? true
802
+ metaFile: config.metaFile ?? true,
803
+ external: resolveExternals(config.external)
665
804
  };
666
805
  }
667
806
  function collectAutoMetaUrlsWarnings(config) {
@@ -713,6 +852,28 @@ function resolvePluginConfig(config) {
713
852
  return { scripts };
714
853
  }
715
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
716
877
  //#region src/serve/gm-shim.ts
717
878
  function shouldShimModule(id) {
718
879
  const cleanId = id.split("\0").pop() ?? id;
@@ -812,14 +973,17 @@ import ${JSON.stringify(entryPath)};
812
973
  }
813
974
  //#endregion
814
975
  //#region src/serve/wrapper.ts
976
+ function matchUserscriptPath(url, fileName, suffix) {
977
+ return (url.split("?")[0] ?? "") === `/${fileName}${suffix}`;
978
+ }
815
979
  function matchDevUserscript(url, fileName) {
816
- return (url.split("?")[0] ?? "") === `/${fileName}.dev.user.js`;
980
+ return matchUserscriptPath(url, fileName, ".dev.user.js");
817
981
  }
818
982
  function matchProxyUserscript(url, fileName) {
819
- return (url.split("?")[0] ?? "") === `/${toProxyFileName(fileName)}`;
983
+ return matchUserscriptPath(url, fileName, ".proxy.user.js");
820
984
  }
821
985
  function matchFileUserscript(url, fileName) {
822
- return (url.split("?")[0] ?? "") === `/${fileName}.user.js`;
986
+ return matchUserscriptPath(url, fileName, ".user.js");
823
987
  }
824
988
  function toInstallFileName(fileName, kind) {
825
989
  return {
@@ -838,17 +1002,17 @@ function toServeEntryPath(root, entry) {
838
1002
  const absolute = resolve(root, entry);
839
1003
  return `/${relative(root, absolute).split(sep).join(posix.sep)}`;
840
1004
  }
841
- function applyServeHeader(header, prefix) {
842
- return withServeGrants({
1005
+ function applyServeHeader(header, prefix, externals = []) {
1006
+ return withServeGrants(withExternalRequires({
843
1007
  ...header,
844
1008
  name: prefix === false ? header.name : `${prefix}${header.name}`
845
- });
1009
+ }, externals));
846
1010
  }
847
1011
  function generateDevWrapper(options) {
848
1012
  const clientUrl = `${options.origin}/@vite/client`;
849
1013
  const entryUrl = `${options.origin}${options.entryPath}`;
850
1014
  const bootstrapUrl = `${options.origin}${REACT_BOOTSTRAP_PATH}?entry=${encodeURIComponent(options.entryPath)}`;
851
- 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 ");
852
1016
  const injectTarget = options.reactPreamble ? bootstrapUrl : entryUrl;
853
1017
  const clientInject = options.reactPreamble ? "" : `
854
1018
  if (!root.${VITE_CLIENT_FLAG}) {
@@ -873,14 +1037,15 @@ ${clientInject}
873
1037
  `;
874
1038
  }
875
1039
  function generateDevUserscript(options) {
876
- return `${generateHeader(applyServeHeader(options.script.header, options.prefix), {
1040
+ return `${generateHeader(applyServeHeader(options.script.header, options.prefix, options.script.external), {
877
1041
  ...options.headerOptions,
878
1042
  fileName: options.script.fileName,
879
1043
  mode: "serve"
880
1044
  })}\n\n${generateDevWrapper({
881
1045
  origin: options.origin,
882
1046
  entryPath: toServeEntryPath(options.root, options.script.entry),
883
- reactPreamble: options.reactPreamble
1047
+ reactPreamble: options.reactPreamble,
1048
+ externals: options.script.external
884
1049
  })}`;
885
1050
  }
886
1051
  function findDevScript(url, scripts) {
@@ -994,6 +1159,49 @@ function configureDevServer(server, resolved, reactPreamble) {
994
1159
  };
995
1160
  }
996
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
997
1205
  //#region src/plugin.ts
998
1206
  function absolutizeEntries(config, root) {
999
1207
  return { scripts: config.scripts.map((script) => ({
@@ -1047,30 +1255,34 @@ function UserscriptPlugin(config) {
1047
1255
  server.config.logger.error(String(error));
1048
1256
  return;
1049
1257
  }
1050
- let timer;
1258
+ const scheduler = createDebouncedSingleFlight(run, 80, (error) => {
1259
+ server.config.logger.error(String(error));
1260
+ });
1051
1261
  const onChange = (file) => {
1052
1262
  if (file.startsWith(outDirAbs)) return;
1053
- clearTimeout(timer);
1054
- timer = setTimeout(() => {
1055
- run().catch((error) => {
1056
- server.config.logger.error(String(error));
1057
- });
1058
- }, 80);
1263
+ scheduler.schedule();
1059
1264
  };
1060
1265
  server.watcher.on("change", onChange);
1061
1266
  server.watcher.on("add", onChange);
1062
- const closeServer = server.close.bind(server);
1063
- server.close = async () => {
1267
+ let cleaned = false;
1268
+ const cleanup = () => {
1269
+ if (cleaned) return;
1270
+ cleaned = true;
1271
+ scheduler.cancel();
1064
1272
  server.watcher.off("change", onChange);
1065
1273
  server.watcher.off("add", onChange);
1066
- clearTimeout(timer);
1274
+ };
1275
+ server.httpServer?.once("close", cleanup);
1276
+ const closeServer = server.close.bind(server);
1277
+ server.close = async () => {
1278
+ cleanup();
1067
1279
  return closeServer();
1068
1280
  };
1069
1281
  };
1070
1282
  return [
1071
1283
  {
1072
1284
  name: `${PLUGIN_NAME}:config`,
1073
- config(userConfig) {
1285
+ config(userConfig, env) {
1074
1286
  const { input, hasHtml } = resolvePluginBuildInput(userConfig, resolved.scripts);
1075
1287
  const scriptNames = new Set(resolved.scripts.map((script) => script.fileName));
1076
1288
  userConfig.build ??= {};
@@ -1080,11 +1292,25 @@ function UserscriptPlugin(config) {
1080
1292
  const userEntryFileNames = userOutput && !Array.isArray(userOutput) ? userOutput.entryFileNames : void 0;
1081
1293
  const openScript = resolved.scripts.find((script) => script.server.open);
1082
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);
1083
1309
  return {
1084
1310
  appType: userConfig.appType ?? (hasHtml ? "spa" : "custom"),
1085
1311
  optimizeDeps: {
1086
1312
  entries: Object.values(input),
1087
- exclude: [VIRTUAL_MODULE_ID]
1313
+ exclude: [VIRTUAL_MODULE_ID, ...specifiers]
1088
1314
  },
1089
1315
  server: {
1090
1316
  cors: userConfig.server?.cors ?? true,
@@ -1093,18 +1319,7 @@ function UserscriptPlugin(config) {
1093
1319
  build: {
1094
1320
  minify: userConfig.build?.minify ?? false,
1095
1321
  assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? Number.MAX_SAFE_INTEGER,
1096
- rolldownOptions: {
1097
- input,
1098
- output: {
1099
- format: "es",
1100
- entryFileNames: (chunkInfo) => {
1101
- if (scriptNames.has(chunkInfo.name)) return "[name].js";
1102
- if (typeof userEntryFileNames === "function") return userEntryFileNames(chunkInfo);
1103
- if (typeof userEntryFileNames === "string") return userEntryFileNames;
1104
- return "assets/[name]-[hash].js";
1105
- }
1106
- }
1107
- }
1322
+ rolldownOptions
1108
1323
  }
1109
1324
  };
1110
1325
  },
@@ -1119,9 +1334,15 @@ function UserscriptPlugin(config) {
1119
1334
  name: `${PLUGIN_NAME}:virtual`,
1120
1335
  resolveId: (id) => {
1121
1336
  if (id === "virtual:vite-userscript-plugin") return RESOLVED_VIRTUAL_MODULE_ID;
1337
+ if (command === "serve" && findResolvedExternal(resolved.scripts, id)) return toExternalModuleId(id);
1122
1338
  },
1123
1339
  load: (id) => {
1124
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);
1125
1346
  }
1126
1347
  },
1127
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.0",
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"