Repository: momocow/webpack-userscript
Branch: main
Commit: dad70898d103
Files: 96
Total size: 144.7 KB
Directory structure:
gitextract_s5o6o3m1/
├── .eslintignore
├── .eslintrc.js
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── docs.yaml
│ ├── release.yaml
│ └── test.yaml
├── .gitignore
├── .husky/
│ └── pre-commit
├── .lintstagedrc.json
├── .prettierrc
├── .releaserc.json
├── LICENSE
├── README.md
├── jest.config.ts
├── lib/
│ ├── const.ts
│ ├── features/
│ │ ├── default-tags.ts
│ │ ├── feature.ts
│ │ ├── fix-tags.ts
│ │ ├── index.ts
│ │ ├── interpolater.ts
│ │ ├── load-headers/
│ │ │ ├── impl.ts
│ │ │ ├── index.ts
│ │ │ └── loaders.ts
│ │ ├── proxy-script.ts
│ │ ├── render-headers.ts
│ │ ├── resolve-base-urls.ts
│ │ ├── ssri.ts
│ │ └── validate-headers/
│ │ ├── headers.ts
│ │ ├── impl.ts
│ │ ├── index.ts
│ │ └── utils.ts
│ ├── fs.ts
│ ├── index.ts
│ ├── plugin.ts
│ ├── types.ts
│ └── utils.ts
├── package.json
├── test/
│ ├── integration/
│ │ ├── default-tags/
│ │ │ ├── fixtures.ts
│ │ │ └── index.test.ts
│ │ ├── fix-tags/
│ │ │ ├── fixtures.ts
│ │ │ ├── headers.txt
│ │ │ └── index.test.ts
│ │ ├── fixtures/
│ │ │ ├── entry.js.txt
│ │ │ ├── entry.min.js.txt
│ │ │ ├── headers.txt
│ │ │ └── package.json.txt
│ │ ├── fixtures.ts
│ │ ├── general/
│ │ │ ├── fixtures.ts
│ │ │ └── index.test.ts
│ │ ├── headers/
│ │ │ ├── fixtures.ts
│ │ │ ├── index.test.ts
│ │ │ ├── pretty-headers.txt
│ │ │ └── tag-order-headers.txt
│ │ ├── i18n/
│ │ │ ├── fixtures.ts
│ │ │ ├── i18n.headers.txt
│ │ │ ├── index.test.ts
│ │ │ └── non-strict-i18n.headers.txt
│ │ ├── interpolater/
│ │ │ ├── fixtures.ts
│ │ │ └── index.test.ts
│ │ ├── load-headers/
│ │ │ ├── fixtures.ts
│ │ │ ├── index.test.ts
│ │ │ └── load-headers.headers.txt
│ │ ├── multi-entry/
│ │ │ ├── entry1.headers.txt
│ │ │ ├── entry2.headers.txt
│ │ │ ├── entry3.headers.txt
│ │ │ ├── fixtures.ts
│ │ │ └── index.test.ts
│ │ ├── package-json/
│ │ │ ├── bugs.headers.txt
│ │ │ ├── fixtures.ts
│ │ │ ├── index.test.ts
│ │ │ └── root-option.headers.txt
│ │ ├── proxy-script/
│ │ │ ├── base-url-proxy-script.headers.txt
│ │ │ ├── base-url-proxy-script.proxy-headers.txt
│ │ │ ├── fixtures.ts
│ │ │ ├── index.test.ts
│ │ │ ├── proxy-script.headers.txt
│ │ │ └── proxy-script.proxy-headers.txt
│ │ ├── resolve-base-urls/
│ │ │ ├── fixtures.ts
│ │ │ └── index.test.ts
│ │ ├── ssri/
│ │ │ ├── algorithms-ssri-headers.txt
│ │ │ ├── algorithms-ssri-lock.json.txt
│ │ │ ├── filters-ssri-headers.txt
│ │ │ ├── filters-ssri-lock.json.txt
│ │ │ ├── fixtures.ts
│ │ │ ├── index.test.ts
│ │ │ ├── multi-algo-ssri-lock.json.txt
│ │ │ ├── ssri-headers.txt
│ │ │ ├── ssri-lock.json.txt
│ │ │ ├── static/
│ │ │ │ └── .eslintignore
│ │ │ └── unsupported-protocols.headers.txt
│ │ ├── util.ts
│ │ └── volume.ts
│ └── setup.ts
├── tsconfig.build.json
├── tsconfig.json
└── typedoc.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintignore
================================================
*.js
================================================
FILE: .eslintrc.js
================================================
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-module-boundary-types': 'error',
'@typescript-eslint/explicit-member-accessibility': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'max-len': [
'error',
{
code: 80,
ignoreComments: true,
}
],
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: '*', next: 'return' },
{ blankLine: 'always', prev: '*', next: 'throw' },
{ blankLine: 'always', prev: '*', next: 'continue' },
],
},
settings: {
'import/resolver': {
typescript: true,
node: true,
},
},
};
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
- https://www.buymeacoffee.com/momocow
================================================
FILE: .github/workflows/docs.yaml
================================================
name: Deploy docs to Pages
on:
push:
branches: ['main']
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: 'pages'
cancel-in-progress: true
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run docs
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
# Upload entire repository
path: 'docs/'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
================================================
FILE: .github/workflows/release.yaml
================================================
name: Release
on:
workflow_dispatch:
push:
branches:
- main
- alpha
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Clean
run: npm run clean
- name: Build
run: npm run build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm run release
================================================
FILE: .github/workflows/test.yaml
================================================
name: Test
on:
- push
- pull_request
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*, node]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm test
================================================
FILE: .gitignore
================================================
.vscode/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
dist/
docs/
================================================
FILE: .husky/pre-commit
================================================
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# ignore errors
npx lint-staged || exit 0
================================================
FILE: .lintstagedrc.json
================================================
{
"**/*.ts": ["prettier --write", "eslint --fix"]
}
================================================
FILE: .prettierrc
================================================
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80
}
================================================
FILE: .releaserc.json
================================================
{
"branches": [
"main",
{
"name": "alpha",
"prerelease": true
}
],
"plugins": [
"semantic-release-gitmoji",
"@semantic-release/github",
"@semantic-release/npm",
[
"@semantic-release/git",
{
"message": ":bookmark: v${nextRelease.version} [skip ci]\n\nhttps://github.com/momocow/webpack-userscript/releases/tag/${nextRelease.gitTag}"
}
]
]
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 MomoCow <momocow.me@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# webpack-userscript
[](https://github.com/momocow/webpack-userscript/actions/workflows/test.yaml)
[](https://github.com/momocow/webpack-userscript/actions/workflows/release.yaml)
[](https://gitmoji.carloscuesta.me/)
[](https://www.npmjs.com/package/webpack-userscript/v/latest)
[](https://gitmoji.carloscuesta.me/)
A Webpack plugin for userscript projects 🙈
- [webpack-userscript](#webpack-userscript)
- [Overview](#overview)
- [Installation](#installation)
- [Usage](#usage)
- [Options](#options)
- [Concepts](#concepts)
- [What does it actually do?](#what-does-it-actually-do)
- [Prepend headers to userscripts](#prepend-headers-to-userscripts)
- [Generate metadata files](#generate-metadata-files)
- [Generate proxyscript files](#generate-proxyscript-files)
- [Headers pipeline](#headers-pipeline)
- [Load headers](#load-headers)
- [Rename ambiguous tags](#rename-ambiguous-tags)
- [Resolve base URLs](#resolve-base-urls)
- [Process SSRIs](#process-ssris)
- [Provide default values for tags](#provide-default-values-for-tags)
- [Generate proxyscripts](#generate-proxyscripts)
- [Interpolate templates inside values](#interpolate-templates-inside-values)
- [Validate headers](#validate-headers)
- [Render headers](#render-headers)
- [Guides](#guides)
- [Hot Development](#hot-development)
- [Integration with Webpack Dev Server and TamperMonkey](#integration-with-webpack-dev-server-and-tampermonkey)
- [I18n headers](#i18n-headers)
- [Furthermore](#furthermore)
## Overview
### Installation
```bash
npm i webpack-userscript -D
```
### Usage
Import and configure the plugin in the `webpack.config.js` as the following example.
```js
const { UserscriptPlugin } = require('webpack-userscript');
module.exports = {
plugins: [new UserscriptPlugin(/* optionally provide more options here */)],
};
```
### Options
See [UserscriptOptions](https://cow.moe/webpack-userscript/types/UserscriptOptions.html) for all configurations.
## Concepts
### What does it actually do?
#### Prepend headers to userscripts
The main purpose of this plugin is to generate userscript headers, and prepend them as a comment block into output entry scripts whose names are conventionally ending with `.user.js`.
There are several userscript engines on the internet and some of them call userscript headers in different names; but don't worry because they share the same concept and **almost** the same format.
Here are some references to headers definitions of userscript engines:
- [TamperMonkey: userscript headers](https://www.tampermonkey.net/documentation.php#meta)
- [GreaseMonkey: metadata block](https://wiki.greasespot.net/Metadata_Block)
- [GreasyFork: meta keys](https://greasyfork.org/en/help/meta-keys)
- [ViolentMonkey: metadata block](https://violentmonkey.github.io/api/metadata-block/)
#### Generate metadata files
Besides prepending headers to entry scripts, it can optionally generate metadata files which are userscript files without codes; that is, they contain headers only. Metadata files are used to save bandwidth when checking updates. By convention, their names are ending with `.meta.js`.
#### Generate proxyscript files
The concept of proxyscript is introduced by this plugin, unlike userscripts and metadata files which are commonly known. The name of a proxyscript should end with `.proxy.user.js`. It is mainly designed to work around the caching behavior of userscript engines like TamperMonkey. It was a pain point for userscript developers who set up a development environment with Webpack Dev Server and require fresh reloads to test their scripts.
> See more details in [issue #63](https://github.com/momocow/webpack-userscript/issues/63)
It is worth mentioning that with ViolentMonkey you might experience a better reload story, according to [a feedback in issue #63](https://github.com/momocow/webpack-userscript/issues/63#issuecomment-1500167848).
### Headers pipeline
#### Load headers
Headers can be provided directly as an object, a string referencing to a file, or a function returning an object of headers.
The plugin will also try to load initial headers from the following fields, `name`, `description`, `version`, `author`, `homepage` and `bugs` in `package.json`.
Headers files are added as a file dependency; therefore, changes to headers files are watched by Webpack during developing in the watch mode.
#### Rename ambiguous tags
The main purpose is to fix misspelling or wrong letter case.
- `updateUrl` => `updateURL`
- `iconUrl` => `iconURL`
- `icon64Url` => `icon64URL`
- `installUrl` => `installURL`
- `supportUrl` => `supportURL`
- `downloadUrl` => `downloadURL`
- `homepageUrl` => `homepageURL`
#### Resolve base URLs
Base URLs are resolved for `downloadURL` and `updateURL`.
If `updateBaseURL` is not provided, `downloadBaseURL` will be used; if metajs is disabled, `updateURL` will point to the file of userjs.
#### Process SSRIs
[Subresource Integrity](https://www.tampermonkey.net/documentation.php#api:Subresource_Integrity) is used to ensure the 3rd-party assets do not get mocked by the man in the middle.
URLs in `@require` and `@resource` tags can have their SSRIs be generated and locked in a SSRI lock file whose name is default to `ssri-lock.json`.
Missing SSRIs will be computed right in the compilation, which indicates that developers have to ensure their 3rd-party assets to be trustable during compilation.
If one cannot ensure 3rd-party assets to be trustable, he can modify the lock file himself with trustable integrities of assets.
> Note that the lock file should be commited into the version control system just like `package-lock.json`.
#### Provide default values for tags
If there is no any `@include` or `@match` tag provided, a wildcard `@match`, `*://*/*`, is used.
#### Generate proxyscripts
The content of a proxyscript looks similar to a metajs except that its `@require` tag will include an URL linked to its userjs file and it won't have any one of these tags, `downloadURL`, `updateURL` and `installURL`.
#### Interpolate templates inside values
Leaf values of headers can be interpolable templates. Template variables can be represented in a format, `[var]`, just like how [template strings in Webpack output options](https://webpack.js.org/configuration/output/#template-strings) look like.
Possible template variables are as follows.
- `[name]`: chunk name
- `[buildNo]`: build number starting from 1 at beginning of watch mode
- `[buildTime]`: the timestamp in millisecond when the compilation starts
- `[file]`: full path of the file
- = `[dirname]` + `[basename]` + `[extname]` + `[query]`
- `[filename]`: file path
- = `[basename]` + `[extname]`
- `[dirname]`: directory path
- `[basename]`: file base name
- `[extname]`: file extension starting with `.`
- `[query]`: query string starting with `?`
> Note that `[buildNo]` starts from 0 and will increase during developing in the watch mode.
> Once exiting from the watch mode, it will be reset.
For example, one can embed the build time into `@version` tag via the following configuration.
```js
new UserscriptPlugin({
headers: {
version: '0.0.1-beta.[buildTime]'
},
})
```
#### Validate headers
Headers will be transformed and validated with the help of [`class-transformer`](https://github.com/typestack/class-transformer) and [`class-validator`](https://github.com/typestack/class-validator).
The configuration defaults to strict mode, which means extra tags are not allowed and type checking to headers values are performed.
One can provide `headersClass` option to override the default `Headers` class; but it is suggested to inherit from the original one.
> Note that the `headersClass` is used for both main headers and i18n headers.
> Check the [default implementation](lib/features/validate-headers/headers.ts) before customizing your own.
#### Render headers
Headers in all locales are merged and rendered.
There are 2 useful options here, `pretty` and `tagOrder`.
The `pretty` option is a boolean deciding whether to render the headers as a table or not.
The `tagOrder` option is a precedence list of tag names which should be followed. Listed tags are rendered first; unlisted tags are rendered after listed ones, in ASCII order.
## Guides
### Hot Development
The following example can be used in development mode with the help of [`webpack-dev-server`](https://github.com/webpack/webpack-dev-server).
`webpack-dev-server` will build the userscript in **watch** mode. Each time the project is built, the `buildNo` variable will increase by 1.
> **Notes**: `buildNo` will be reset to 0 if the dev server is terminated. In this case, if you expect the build version to be persisted during dev server restarting, you can use the `buildTime` variable instead.
In the following configuration, a portion of the `version` contains the `buildNo`; therefore, each time there is a build, the `version` is also increased so as to indicate a new update available for the script engine like Tampermonkey or GreaseMonkey.
After the first time starting the `webpack-dev-server`, you can install the script via `http://localhost:8080/<project-name>.user.js` (the URL is actually refered to your configuration of `webpack-dev-server`). Once installed, there is no need to manually reinstall the script until you stop the server. To update the script, the script engine has an **update** button on the GUI for you.
- `webpack.config.dev.js`
```js
const path = require('path');
const { UserscriptPlugin } = require('webpack-userscript');
const dev = process.env.NODE_ENV === 'development';
module.exports = {
mode: dev ? 'development' : 'production',
entry: path.resolve(__dirname, 'src', 'index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: '<project-name>.user.js',
},
devServer: {
contentBase: path.join(__dirname, 'dist'),
},
plugins: [
new UserscriptPlugin({
headers(original) {
if (dev) {
return {
...original,
version: `${original.version}-build.[buildNo]`,
}
}
return original;
},
}),
],
};
```
### Integration with Webpack Dev Server and TamperMonkey
If you feel tired with firing the update button on TamperMonkey GUI, maybe you can have a try at proxy script.
A proxy script actually looks similar with the content of `*.meta.js` except that it contains additional `@require` field to include the main userscript. A proxy script is used since TamperMonkey has an option that makes external scripts always be update-to-date without caching, and external scripts are included into userscripts via the `@require` meta field. (You may also want to read this issue, [Tampermonkey/tampermonkey#767](https://github.com/Tampermonkey/tampermonkey/issues/767#issuecomment-542813282))
To avoid caching and make the main script always be updated after each page refresh, we have to make our main script **"an external resource"**. That is where the proxy script comes in, it provides TamperMonkey with a `@require` pointint to the URL of the main script on the dev server, and each time you reload your testing page, it will trigger the update.
> Actually it requires 2 reloads for each change to take effect on the page. The first reload trigger the update of external script but without execution (it runs the legacy version of the script), the second reload will start to run the updated script.
>
> I have no idea why TamperMonkey is desinged this way. But..., it's up to you!
To enable the proxy script, provide a `proxyScript` configuration to the plugin constructor.
`baseURL` should be the base URL of the dev server, and the `filename` is for the proxy script.
> Note: `filename` will be interpolated.
After starting the dev server, you can find your proxy script under `<baseURL>/<filename>`. In the example below, assume the entry filename is `index.js`, you should visit `http://127.0.0.1:12345/index.proxy.user.js` to install the proxy script on TamperMonkey.
See [Issue#63](https://github.com/momocow/webpack-userscript/issues/63) for more information.
```js
new WebpackUserscript({
// <...your other configs...>,
proxyScript: {
baseURL: 'http://127.0.0.1:12345',
filename: '[basename].proxy.user.js',
},
});
```
### I18n headers
I18n headers can be provided as an object, a string (a.k.a headers file) or a function (a.k.a headers provider), just like the main headers.
```js
new UserscriptPlugin({
headers: {
name: 'this is the main script name'
},
i18n: {
// headers object
'en-US': {
name: 'this is a localized name'
},
},
})
```
```js
new UserscriptPlugin({
headers: {
name: 'this is the main script name'
},
i18n: {
// headers file
'en-US': '/dir/to/headers.json' // whose content is `{"name": "this is a localized name"}`
},
})
```
```js
new UserscriptPlugin({
headers: {
name: 'this is the main script name'
},
i18n: {
// headers provider
'en-US': (headers) => ({
...headers,
name: 'this is a localized name'
}),
},
})
```
With the above configurations will generate the following headers,
```js
// ==UserScript==
// @name this is the main script name
// @name:en-US this is a localized name
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
```
## Furthermore
- [Get started with Webpack](https://webpack.js.org/guides/getting-started/)
- [How to write userscript in TypeScript?](https://github.com/momocow/webpack-userscript/issues/95)
- [Solution to userscript not refreshing on every page load](https://github.com/momocow/webpack-userscript/issues/63)
================================================
FILE: jest.config.ts
================================================
import { pathsToModuleNameMapper } from 'ts-jest';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { compilerOptions } = require('./tsconfig.json');
const EXCLUDE_PATHS = new Set(['class-transformer/cjs/storage']);
module.exports = {
clearMocks: true,
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',
'**/?(*.)+(spec|test).+(ts|tsx|js)',
],
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
diagnostics: { warnOnly: process.env.NODE_ENV === 'development' },
},
],
},
modulePaths: [compilerOptions.baseUrl],
moduleNameMapper: pathsToModuleNameMapper(
Object.fromEntries(
Object.entries(compilerOptions.paths).filter(
(e): e is [string, string[]] => !EXCLUDE_PATHS.has(e[0]),
),
),
),
setupFiles: ['./test/setup.ts'],
setupFilesAfterEnv: ['jest-extended/all'],
};
================================================
FILE: lib/const.ts
================================================
export const DEFAULT_LOCALE_KEY = '';
================================================
FILE: lib/features/default-tags.ts
================================================
import { DEFAULT_LOCALE_KEY } from '..//const';
import { UserscriptPluginInstance } from '../types';
import { Feature } from './feature';
export class SetDefaultTags extends Feature {
public readonly name = 'SetDefaultTags';
public apply({ hooks }: UserscriptPluginInstance): void {
hooks.headers.tap(this.constructor.name, (headers, { locale }) => {
if (
locale === DEFAULT_LOCALE_KEY &&
headers.include === undefined &&
headers.match === undefined
) {
return {
...headers,
match: '*://*/*',
};
}
return headers;
});
}
}
================================================
FILE: lib/features/feature.ts
================================================
import { UserscriptPluginInstance } from '../types';
export abstract class Feature<Options = unknown> {
public constructor(public readonly options: Options) {}
public abstract readonly name: string;
public abstract apply(plugin: UserscriptPluginInstance): void;
}
================================================
FILE: lib/features/fix-tags.ts
================================================
import { StrictHeadersProps, UserscriptPluginInstance } from '../types';
import { Feature } from './feature';
export class FixTags extends Feature {
public readonly name = 'FixTags';
public readonly fixableTagNames = new Map<string, keyof StrictHeadersProps>([
['updateUrl', 'updateURL'],
['iconUrl', 'iconURL'],
['icon64Url', 'icon64URL'],
['installUrl', 'installURL'],
['supportUrl', 'supportURL'],
['downloadUrl', 'downloadURL'],
['homepageUrl', 'homepageURL'],
]);
public apply({ hooks }: UserscriptPluginInstance): void {
hooks.headers.tap(this.name, (headers) => {
for (const [source, target] of this.fixableTagNames) {
if (headers[source] !== undefined) {
if (headers[target] !== undefined) {
throw new Error(`ambiguous tags: ("${source}", "${target}")`);
}
headers = {
...headers,
[source]: undefined,
[target]: headers[source],
};
}
}
return headers;
});
}
}
================================================
FILE: lib/features/index.ts
================================================
export * from './default-tags';
export * from './feature';
export * from './fix-tags';
export * from './interpolater';
export * from './load-headers';
export * from './proxy-script';
export * from './render-headers';
export * from './resolve-base-urls';
export * from './ssri';
export * from './validate-headers';
================================================
FILE: lib/features/interpolater.ts
================================================
import {
HeadersProps,
UserscriptPluginInstance,
ValueType,
WaterfallContext,
} from '../types';
import { Feature } from './feature';
export class Interpolater extends Feature {
public readonly name = 'Interpolater';
public apply({ hooks }: UserscriptPluginInstance): void {
hooks.headers.tap(this.name, (headers, ctx) =>
this.interpolate(headers, this.getVariables(ctx)),
);
hooks.proxyHeaders.tap(this.name, (headers, ctx) =>
this.interpolate(headers, this.getVariables(ctx)),
);
hooks.proxyScriptFile.tap(this.name, (filepath, ctx) =>
this.interpolate(filepath, this.getVariables(ctx)),
);
}
private getVariables({
fileInfo: {
chunk,
originalFile,
filename,
basename,
query,
dirname,
extname,
},
buildNo,
buildTime,
}: WaterfallContext): Record<string, string> {
return {
name: chunk.name ?? '',
file: originalFile,
filename,
basename,
query,
dirname,
extname,
buildNo: buildNo.toString(),
buildTime: buildTime.toISOString(),
};
}
private interpolate(
data: HeadersProps,
info: Record<string, string>,
): HeadersProps;
private interpolate<T extends ValueType>(
data: T,
info: Record<string, string>,
): T;
private interpolate(
data: HeadersProps | ValueType,
info: Record<string, string>,
): HeadersProps | ValueType {
if (typeof data === 'string') {
return Object.entries(info).reduce((value, [dataKey, dataVal]) => {
return value.replace(new RegExp(`\\[${dataKey}\\]`, 'g'), `${dataVal}`);
}, data);
}
if (Array.isArray(data)) {
return data.map((item) => this.interpolate(item, info));
}
if (typeof data === 'object' && data !== null) {
return Object.fromEntries(
Object.entries(data).map(([k, v]) => [
this.interpolate(k, info),
this.interpolate(v, info),
]),
) as HeadersProps;
}
return data;
}
}
================================================
FILE: lib/features/load-headers/impl.ts
================================================
import { DEFAULT_LOCALE_KEY } from '../../const';
import {
HeadersProps,
UserscriptPluginInstance,
WaterfallContext,
} from '../../types';
import { Feature } from '../feature';
import {
FileLoader,
HeadersFile,
HeadersProvider,
ObjectLoader,
PackageLoader,
ProviderLoader,
} from './loaders';
export type HeadersOption = HeadersProps | HeadersFile | HeadersProvider;
export interface LoadHeadersOptions {
root?: string;
headers?: HeadersOption;
i18n?: Record<string, HeadersOption>;
}
export class LoadHeaders extends Feature<LoadHeadersOptions> {
public readonly name = 'LoadHeaders';
private packageLoader!: PackageLoader;
private objectLoaders: Map<string, ObjectLoader> = new Map();
private fileLoaders: Map<string, FileLoader> = new Map();
private providerLoaders: Map<string, ProviderLoader> = new Map();
public apply({ hooks }: UserscriptPluginInstance): void {
const { headers: headersOption, i18n = {}, root } = this.options;
this.packageLoader = new PackageLoader(root);
if (headersOption) {
this.addLoader(DEFAULT_LOCALE_KEY, headersOption);
}
for (const [locale, i18nHeadersOption] of Object.entries(i18n)) {
this.addLoader(locale, i18nHeadersOption);
}
hooks.init.tapPromise(this.name, async (compiler) => {
await this.packageLoader.load(compiler);
});
if (this.fileLoaders.size > 0) {
hooks.preprocess.tapPromise(this.name, async (compilation) => {
await Promise.all(
Array.from(this.fileLoaders.values()).map((fileLoader) =>
fileLoader.load(compilation),
),
);
});
}
hooks.headers.tapPromise(this.name, async (_, ctx) =>
this.provideHeaders(ctx),
);
}
private addLoader(locale: string, headersOption: HeadersOption): void {
if (typeof headersOption === 'object') {
this.objectLoaders.set(locale, new ObjectLoader(headersOption));
return;
}
if (typeof headersOption === 'string') {
this.fileLoaders.set(
locale,
new FileLoader(headersOption, this.options.root),
);
return;
}
this.providerLoaders.set(locale, new ProviderLoader(headersOption));
}
private async provideHeaders(ctx: WaterfallContext): Promise<HeadersProps> {
const { locale } = ctx;
const headersBase = {};
if (locale === DEFAULT_LOCALE_KEY) {
Object.assign(headersBase, this.packageLoader.headers);
}
Object.assign(headersBase, this.objectLoaders.get(locale)?.headers);
Object.assign(headersBase, this.fileLoaders.get(locale)?.headers);
return (
this.providerLoaders.get(locale)?.load(headersBase, ctx) ?? headersBase
);
}
}
================================================
FILE: lib/features/load-headers/index.ts
================================================
export * from './impl';
export * from './loaders';
================================================
FILE: lib/features/load-headers/loaders.ts
================================================
import path from 'node:path';
import { promisify } from 'node:util';
import { Compilation, Compiler } from 'webpack';
import { findPackage, FsReadFile, FsStat, readJSON } from '../../fs';
import { HeadersProps, WaterfallContext } from '../../types';
export class ObjectLoader {
public constructor(public headers: HeadersProps) {}
public load(): HeadersProps {
return this.headers;
}
}
export type HeadersFile = string;
export class FileLoader {
public headers?: HeadersProps;
private headersFileTimestamp = 0;
public constructor(private file: HeadersFile, private root?: string) {}
public async load(compilation: Compilation): Promise<HeadersProps> {
const getFileTimestampAsync = promisify(
compilation.fileSystemInfo.getFileTimestamp.bind(
compilation.fileSystemInfo,
),
);
const resolvedHeadersFile = path.resolve(
this.root ?? compilation.compiler.context,
this.file,
);
const ts = await getFileTimestampAsync(resolvedHeadersFile);
if (
this.headers &&
ts &&
typeof ts === 'object' &&
typeof ts.timestamp === 'number' &&
this.headersFileTimestamp >= ts.timestamp
) {
// file no change
return this.headers;
}
if (ts && typeof ts === 'object') {
this.headersFileTimestamp = ts.timestamp ?? this.headersFileTimestamp;
}
compilation.fileDependencies.add(resolvedHeadersFile);
return (this.headers = await readJSON<HeadersProps>(
resolvedHeadersFile,
compilation.inputFileSystem as FsReadFile,
));
}
}
export type HeadersProvider = (
headers: HeadersProps,
ctx: WaterfallContext,
) => HeadersProps | Promise<HeadersProps>;
export class ProviderLoader {
public constructor(private provider: HeadersProvider) {}
public async load(
headers: HeadersProps,
ctx: WaterfallContext,
): Promise<HeadersProps> {
return this.provider(headers, ctx);
}
}
interface PackageInfo {
name?: string;
version?: string;
description?: string;
author?: string;
homepage?: string;
bugs?: string | { url?: string };
}
export class PackageLoader {
public headers?: HeadersProps;
public constructor(private root?: string) {}
public async load(compiler: Compiler): Promise<HeadersProps> {
if (!this.headers) {
try {
const cwd = await findPackage(
this.root ?? compiler.context,
compiler.inputFileSystem as FsStat,
);
const packageJson = await readJSON<PackageInfo>(
path.join(cwd, 'package.json'),
compiler.inputFileSystem as FsReadFile,
);
this.headers = {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
author: packageJson.author,
homepage: packageJson.homepage,
supportURL:
typeof packageJson.bugs === 'string'
? packageJson.bugs
: typeof packageJson.bugs === 'object' &&
typeof packageJson.bugs.url === 'string'
? packageJson.bugs.url
: undefined,
};
} catch (e) {
this.headers = {};
}
}
return this.headers;
}
}
================================================
FILE: lib/features/proxy-script.ts
================================================
import { URL } from 'node:url';
import { UserscriptPluginInstance } from '../types';
import { Feature } from './feature';
export interface ProxyScriptFeatureOptions {
filename?: string;
baseURL?: string;
}
export interface ProxyScriptOptions {
proxyScript?: ProxyScriptFeatureOptions;
}
export class ProcessProxyScript extends Feature<ProxyScriptOptions> {
public readonly name = 'ProcessProxyScript';
public apply({ hooks }: UserscriptPluginInstance): void {
const { proxyScript } = this.options;
if (proxyScript) {
hooks.proxyHeaders.tap(
this.name,
(headers, { fileInfo: { userjsFile } }) => {
const devBaseUrl = !proxyScript.baseURL
? 'http://localhost:8080/'
: proxyScript.baseURL;
const requireTags = Array.isArray(headers.require)
? headers.require
: typeof headers.require === 'string'
? [headers.require]
: [];
headers = {
...headers,
require: [
...requireTags,
new URL(userjsFile, devBaseUrl).toString(),
],
downloadURL: undefined,
updateURL: undefined,
installURL: undefined,
};
return headers;
},
);
hooks.proxyScriptFile.tap(this.name, () => {
if (!proxyScript.filename) {
return '[basename].proxy.user.js';
} else {
return proxyScript.filename;
}
});
}
}
}
================================================
FILE: lib/features/render-headers.ts
================================================
import { getBorderCharacters, table } from 'table';
import { DEFAULT_LOCALE_KEY } from '../const';
import {
HeadersProps,
TagType,
UserscriptPluginInstance,
ValueType,
} from '../types';
import { Feature } from './feature';
export interface RenderHeadersOptions {
prefix?: string;
suffix?: string;
pretty?: boolean;
tagOrder?: TagType[];
proxyScript?: unknown;
}
export class RenderHeaders extends Feature<RenderHeadersOptions> {
public readonly name = 'RenderHeaders';
public apply({ hooks }: UserscriptPluginInstance): void {
hooks.renderHeaders.tap(this.name, (headersMap) =>
this.render(this.mergeHeadersMap(headersMap), this.options),
);
if (this.options.proxyScript) {
hooks.renderProxyHeaders.tap(this.name, (headers) =>
this.render(headers, this.options),
);
}
}
private mergeHeadersMap(headersMap: Map<string, HeadersProps>): HeadersProps {
return Array.from(headersMap)
.map(
([locale, headers]) =>
Object.fromEntries(
Object.entries(headers).map(([tag, value]) => [
locale === DEFAULT_LOCALE_KEY ? tag : `${tag}:${locale}`,
value,
]),
) as HeadersProps,
)
.reduce((h1, h2) => ({ ...h1, ...h2 }));
}
private render(
headers: HeadersProps,
{
prefix = '// ==UserScript==\n',
suffix = '// ==/UserScript==\n',
pretty = false,
tagOrder = [
'name',
'description',
'version',
'author',
'homepage',
'supportURL',
'include',
'exclude',
'match',
],
}: RenderHeadersOptions = {},
): string {
const orderRevMap = new Map(tagOrder.map((tag, index) => [tag, index]));
const rows = Object.entries(headers)
.sort(
([tag1], [tag2]) =>
(orderRevMap.get(this.getTagName(tag1)) ?? orderRevMap.size) -
(orderRevMap.get(this.getTagName(tag2)) ?? orderRevMap.size) ||
(tag1 > tag2 ? 1 : tag1 < tag2 ? -1 : 0),
)
.flatMap(([tag, value]) => this.renderTag(tag, value));
const body = pretty
? table(rows, {
border: getBorderCharacters('void'),
columnDefault: {
paddingLeft: 0,
paddingRight: 1,
},
drawHorizontalLine: () => false,
})
.split('\n')
.map((line) => line.trim())
.join('\n')
: rows.map((cols) => cols.join(' ')).join('\n') + '\n';
return prefix + body + suffix;
}
protected renderTag(tag: TagType, value: ValueType): string[][] {
if (Array.isArray(value)) {
return value.map((v) => [`// @${tag}`, v ?? '']);
}
if (typeof value === 'object') {
return Object.entries(value).map(([k, v]) => [
`// @${tag}`,
`${k} ${v ?? ''}`,
]);
}
if (typeof value === 'string') {
return [[`// @${tag}`, value]];
}
if (value === true) {
return [[`// @${tag}`, '']];
}
return [];
}
private getTagName(tag: string): string {
return tag.replace(/:.+$/, '');
}
}
================================================
FILE: lib/features/resolve-base-urls.ts
================================================
import { URL } from 'node:url';
import { UserscriptPluginInstance } from '../types';
import { Feature } from './feature';
export interface ResolveBaseURLsOptions {
downloadBaseURL?: string | URL;
updateBaseURL?: string | URL;
metajs?: boolean;
}
export class ResolveBaseURLs extends Feature<ResolveBaseURLsOptions> {
public readonly name = 'ResolveBaseURLs';
public apply({ hooks }: UserscriptPluginInstance): void {
const { metajs, downloadBaseURL, updateBaseURL } = this.options;
if (downloadBaseURL === undefined) {
return;
}
hooks.headers.tap(
this.name,
(headers, { fileInfo: { userjsFile, metajsFile } }) => {
if (headers.downloadURL === undefined) {
headers = {
...headers,
downloadURL: new URL(userjsFile, downloadBaseURL).toString(),
};
}
if (headers.updateURL === undefined) {
headers = {
...headers,
updateURL: new URL(
metajs ? metajsFile : userjsFile,
updateBaseURL ?? downloadBaseURL,
).toString(),
};
}
return headers;
},
);
}
}
================================================
FILE: lib/features/ssri.ts
================================================
import path from 'node:path';
import fetch from 'node-fetch';
import pLimit from 'p-limit';
import {
fromStream,
IntegrityMap,
parse as parseSSRI,
stringify as stringifySSRI,
} from 'ssri';
import { Readable } from 'stream';
import { URL } from 'url';
import {
FsMkdir,
FsReadFile,
FsWriteFile,
mkdirp,
readJSON,
writeJSON,
} from '../fs';
import { HeadersProps, UserscriptPluginInstance } from '../types';
import { Feature } from './feature';
export type RawSSRILock = Record<string, string>;
export type SSRILock = Record<string, IntegrityMap>;
export type SSRIAlgorithm = 'sha256' | 'sha384' | 'sha512';
export type SSRITag = 'require' | 'resource';
export type URLFilter = (tag: SSRITag, value: URL) => boolean;
export interface SSRIFeatureOptions {
include?: URLFilter;
exclude?: URLFilter;
algorithms?: SSRIAlgorithm[];
strict?: boolean;
lock?: boolean | string;
concurrency?: number;
}
export interface SSRIOptions {
root?: string;
ssri?: SSRIFeatureOptions;
}
export class ProcessSSRI extends Feature<SSRIOptions> {
public readonly name = 'ProcessSSRI';
public readonly allowedProtocols = new Set(['http:', 'https:']);
private ssriLockDirty = false;
private ssriLock: SSRILock = {};
private readonly limit = pLimit(
(typeof this.options.ssri === 'object'
? this.options.ssri.concurrency
: undefined) ?? 6,
);
public apply({ hooks }: UserscriptPluginInstance): void {
const { ssri, root } = this.options;
if (!ssri) {
return;
}
// read lock
hooks.init.tapPromise(this.name, async (compiler) => {
const lockfile = this.getSSRILockFile(ssri);
if (!lockfile) {
return;
}
try {
this.ssriLock = this.parseSSRILock(
await readJSON<RawSSRILock>(
path.resolve(root ?? compiler.context, lockfile),
compiler.inputFileSystem as FsReadFile,
),
);
} catch {}
this.ssriLockDirty = false;
});
// write lock
hooks.close.tapPromise(this.name, async (compiler) => {
const lock = this.getSSRILockFile(ssri);
if (!lock) {
return;
}
const lockfile = path.resolve(root ?? compiler.context, lock);
if (this.ssriLockDirty) {
const { intermediateFileSystem } = compiler;
const dir = path.dirname(lockfile);
const isNotRoot = path.dirname(dir) !== dir;
if (isNotRoot) {
await mkdirp(dir, intermediateFileSystem as FsMkdir);
}
await writeJSON(
lockfile,
this.toRawSSRILock(this.ssriLock),
intermediateFileSystem as FsWriteFile,
);
this.ssriLockDirty = false;
}
});
hooks.headers.tapPromise(this.name, async (headers) => {
const targetURLs = this.getTargetURLs(headers, ssri).reduce(
(map, url) => map.set(url, this.normalizeURL(url)),
new Map<string, string>(),
);
if (targetURLs.size === 0) {
return headers;
}
// merge integrities from ssri-lock
// and those provided within headers option (in respective tags)
for (const [url, normalizedURL] of targetURLs) {
const integrity = parseSSRI(this.parseSSRILike(url), {
strict: true,
}) as IntegrityMap | null;
if (integrity) {
if (this.ssriLock[normalizedURL]) {
integrity.merge(this.ssriLock[normalizedURL], {
strict: true,
});
}
this.ssriLockDirty = true;
this.ssriLock[normalizedURL] = integrity;
}
}
// compute and merge missing hashes based on specified algorithms option
await Promise.all(
Array.from(targetURLs.values()).map(async (normalizedURL) => {
const expectedAlgorithms = ssri.algorithms ?? ['sha512'];
const missingAlgorithms = expectedAlgorithms.filter(
(alg) => !this.ssriLock[normalizedURL]?.[alg],
);
const newIntegrity =
missingAlgorithms.length > 0
? await this.limit(() =>
this.computeSSRI(normalizedURL, missingAlgorithms, {
strict: ssri.strict,
}),
)
: null;
if (newIntegrity) {
if (this.ssriLock[normalizedURL]) {
newIntegrity.merge(this.ssriLock[normalizedURL]);
}
this.ssriLock[normalizedURL] = newIntegrity;
this.ssriLockDirty = true;
}
}),
);
return {
...headers,
...this.patchHeaders(headers, this.ssriLock),
};
});
}
private getSSRILockFile({ lock = true }: SSRIFeatureOptions = {}):
| string
| undefined {
return typeof lock === 'string'
? lock
: lock
? 'ssri-lock.json'
: undefined;
}
private getTargetURLs(
headers: HeadersProps,
options: Pick<SSRIFeatureOptions, 'include' | 'exclude'>,
): string[] {
const urls: string[] = [];
if (headers.require !== undefined) {
const requireURLs = (
Array.isArray(headers.require) ? headers.require : [headers.require]
).filter((url): url is string => typeof url === 'string');
for (const urlStr of requireURLs) {
const url = this.parseURL(urlStr);
if (this.filterURL(url, 'require', options)) {
urls.push(this.stringifyURL(url));
}
}
}
if (headers.resource !== undefined) {
for (const urlStr of Object.values(headers.resource).filter(
(url): url is string => typeof url === 'string',
)) {
const url = this.parseURL(urlStr);
if (this.filterURL(url, 'resource', options)) {
urls.push(this.stringifyURL(url));
}
}
}
return urls;
}
private normalizeURL(url: string): string {
const u = new URL('', this.parseURL(url));
u.hash = '';
return u.toString();
}
private filterURL(
url: URL,
tag: SSRITag,
{ include, exclude }: Pick<SSRIFeatureOptions, 'include' | 'exclude'> = {},
): boolean {
if (!this.allowedProtocols.has(url.protocol)) {
return false;
}
if (include && !include(tag, url)) {
return false;
}
if (exclude && exclude(tag, url)) {
return false;
}
return true;
}
private async computeSSRI(
url: string,
algorithms: SSRIAlgorithm[],
{ strict }: SSRIFeatureOptions,
): Promise<IntegrityMap> {
const response = await fetch(url);
if (response.status !== 200 || response.body === null) {
throw new Error(
`Failed to fetch SSRI sources. ` +
`[${response.status} ${response.statusText}] ${url}`,
);
}
return await fromStream(response.body as Readable, {
algorithms,
strict,
});
}
private parseSSRILike(url: string): string {
return this.parseURL(url).hash.slice(1).replace(/[,;]/g, ' ');
}
private parseSSRILock(rawSSRILock: RawSSRILock): SSRILock {
return Object.fromEntries(
Object.entries(rawSSRILock).map(([url, integrityLike]) => [
url,
parseSSRI(integrityLike, { strict: true }),
]),
);
}
private toRawSSRILock(ssriLock: SSRILock): RawSSRILock {
return Object.fromEntries(
Object.entries(ssriLock).map(
([url, integrity]) =>
[url, stringifySSRI(integrity, { strict: true })] as const,
),
);
}
private parseURL(url: string): URL {
return new URL(url);
}
private stringifyURL(url: URL): string {
return url.toString();
}
private updateURL(url: string, ssriLock: SSRILock): string {
const integrity = ssriLock[url];
if (!integrity) return url;
const urlObj = this.parseURL(url);
urlObj.hash = '#' + stringifySSRI(integrity, { sep: ',', strict: true });
return urlObj.toString();
}
private patchHeaders(
headers: HeadersProps,
ssriLock: SSRILock,
): HeadersProps {
const headersProps: HeadersProps = {};
if (headers.require !== undefined) {
if (Array.isArray(headers.require)) {
headersProps.require = headers.require
.filter((url): url is string => typeof url === 'string')
.map((url) => this.updateURL(url, ssriLock));
} else {
headersProps.require = this.updateURL(headers.require, ssriLock);
}
}
if (headers.resource !== undefined) {
headersProps.resource = Object.fromEntries(
Object.entries(headers.resource)
.filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
)
.map(([name, url]) => [name, this.updateURL(url, ssriLock)]),
);
}
return headersProps;
}
}
================================================
FILE: lib/features/validate-headers/headers.ts
================================================
import {
CompatibilityValue,
InjectInto,
MultiValue,
NamedValue,
RunAt,
Sandbox,
SingleValue,
StrictHeadersProps,
SwitchValue,
} from '../../types';
import {
IsDefined,
IsEnumValue,
IsMultiValue,
IsNamedValue,
IsNestedValue,
IsOptional,
IsSingleValue,
IsSwitchValue,
IsUnique,
IsURLValue,
partialGroups,
} from './utils';
export enum ValidationGroup {
Main = 'main',
I18n = 'i18n',
}
export const Main = partialGroups(ValidationGroup.Main);
export const I18n = partialGroups(ValidationGroup.I18n);
export const Always = partialGroups(ValidationGroup.Main, ValidationGroup.I18n);
export class Compatibility implements CompatibilityValue {
[x: string]: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly firefox?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly chrome?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly opera?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly safari?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly edge?: SingleValue;
}
export class Headers implements StrictHeadersProps {
@Main(IsDefined(), IsSingleValue())
@I18n(IsOptional(), IsSingleValue())
public readonly name?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly version?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly namespace?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly author?: SingleValue;
@Always(IsOptional(), IsSingleValue())
public readonly description?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('homepage'))
public readonly homepage?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('homepage'))
public readonly homepageURL?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('homepage'))
public readonly website?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('homepage'))
public readonly source?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('icon'))
public readonly icon?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('icon'))
public readonly iconURL?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('icon'))
public readonly defaulticon?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('icon64'))
public readonly icon64?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('icon64'))
public readonly icon64URL?: SingleValue;
@Main(IsOptional(), IsURLValue())
public readonly updateURL?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('downloadURL'))
public readonly downloadURL?: SingleValue;
@Main(IsOptional(), IsURLValue(), IsUnique('downloadURL'))
public readonly installURL?: SingleValue;
@Main(IsOptional(), IsURLValue())
public readonly supportURL?: SingleValue;
@Main(IsOptional(), IsMultiValue())
public readonly include?: MultiValue;
@Main(IsOptional(), IsMultiValue())
public readonly match?: MultiValue;
@Main(IsOptional(), IsMultiValue())
public readonly 'exclude-match'?: MultiValue;
@Main(IsOptional(), IsMultiValue())
public readonly exclude?: MultiValue;
@Main(IsOptional(), IsMultiValue())
public readonly require?: MultiValue;
@Main(IsOptional(), IsNamedValue())
public readonly resource?: NamedValue;
@Main(IsOptional(), IsMultiValue())
public readonly connect?: MultiValue;
@Main(IsOptional(), IsMultiValue())
public readonly grant?: MultiValue;
@Main(IsOptional(), IsMultiValue())
public readonly webRequest?: MultiValue;
@Main(IsOptional(), IsSwitchValue())
public readonly noframes?: SwitchValue;
@Main(IsOptional(), IsSwitchValue())
public readonly unwrap?: SwitchValue;
@Always(IsOptional(), IsNamedValue())
public readonly antifeature?: NamedValue;
@Main(IsOptional(), IsEnumValue(RunAt))
public readonly 'run-at'?: RunAt;
@Main(IsOptional(), IsSingleValue())
public readonly copyright?: SingleValue;
@Main(IsOptional(), IsEnumValue(Sandbox))
public readonly sandbox?: Sandbox;
@Main(IsOptional(), IsEnumValue(InjectInto))
public readonly 'inject-into'?: InjectInto;
@Main(IsOptional(), IsSingleValue())
public readonly license?: SingleValue;
@Main(IsOptional(), IsURLValue())
public readonly contributionURL?: SingleValue;
@Main(IsOptional(), IsSingleValue())
public readonly contributionAmount?: SingleValue;
@Main(IsOptional(), IsNestedValue(Compatibility))
public readonly compatible?: Compatibility;
@Main(IsOptional(), IsNestedValue(Compatibility))
public readonly incompatible?: Compatibility;
}
================================================
FILE: lib/features/validate-headers/impl.ts
================================================
import { instanceToPlain, plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { DEFAULT_LOCALE_KEY } from '../../const';
import { HeadersProps, UserscriptPluginInstance } from '../../types';
import { Feature } from '../feature';
import { Headers, ValidationGroup } from './headers';
export interface HeadersValidatorOptions {
strict?: boolean;
whitelist?: boolean;
}
export type HeaderClass = { new (): object };
export interface ValidateHeadersOptions extends HeadersValidatorOptions {
proxyScript?: unknown;
headersClass?: HeaderClass;
}
export class ValidateHeaders extends Feature<ValidateHeadersOptions> {
public readonly name = 'ValidateHeaders';
public apply({ hooks }: UserscriptPluginInstance): void {
const HeadersClass = this.options.headersClass ?? Headers;
hooks.headers.tap(this.name, (headersProps, { locale }) =>
this.validateHeaders(locale, headersProps, HeadersClass, this.options),
);
if (this.options.proxyScript) {
hooks.proxyHeaders.tap(this.name, (headersProps, { locale }) =>
this.validateHeaders(locale, headersProps, HeadersClass, this.options),
);
}
}
private validateHeaders(
locale: string,
headersProps: HeadersProps,
HeadersClass: HeaderClass,
{ whitelist, strict }: HeadersValidatorOptions = {},
): HeadersProps {
const validatorGroups = [
locale === DEFAULT_LOCALE_KEY
? ValidationGroup.Main
: ValidationGroup.I18n,
];
const transformerGroups = whitelist
? validatorGroups
: [ValidationGroup.Main, ValidationGroup.I18n];
const headers = plainToInstance(HeadersClass, headersProps, {
exposeDefaultValues: true,
excludeExtraneousValues: whitelist,
exposeUnsetFields: false,
groups: transformerGroups,
});
if (strict) {
const errors = validateSync(headers, {
forbidNonWhitelisted: true,
whitelist: true,
stopAtFirstError: false,
groups: validatorGroups,
});
if (errors.length > 0) {
throw new Error(
`Validation groups: ${validatorGroups}\n` +
errors
.map((err) => err.toString(undefined, undefined, undefined, true))
.join('\n'),
);
}
}
return instanceToPlain(headers, {
exposeUnsetFields: false,
groups: transformerGroups,
});
}
}
================================================
FILE: lib/features/validate-headers/index.ts
================================================
export * from './headers';
export * from './impl';
export * from './utils';
================================================
FILE: lib/features/validate-headers/utils.ts
================================================
import * as transformer from 'class-transformer';
import { defaultMetadataStorage } from 'class-transformer/cjs/storage';
import * as validator from 'class-validator';
import { applyDecorators, IsRecord, MutuallyExclusive } from '../../utils';
export interface GroupsOptions {
groups?: string[];
}
export type Validator = (options?: GroupsOptions) => PropertyDecorator;
export type ValidatorFactory<T extends any[] = []> = (...args: T) => Validator;
/**
* `@Expose()` from class-transformer is not stackable,
* wrap it in a new `@Expose()` implementation to stack for `groups` options.
*/
export const Expose: Validator =
(options: transformer.ExposeOptions = {}) =>
(target, prop) => {
const metadata = defaultMetadataStorage.findExposeMetadata(
target.constructor,
prop as string,
);
if (!metadata) {
transformer.Expose(options)(target, prop);
return;
}
// merge expose options
Object.assign(
metadata.options,
options,
options.groups
? {
groups: (metadata.options.groups ?? []).concat(options.groups),
}
: undefined,
);
};
export const partialGroups =
(...groups: string[]) =>
(...decorators: Validator[]): PropertyDecorator =>
applyDecorators(...decorators.map((deco) => deco({ groups })));
export const IsOptional: ValidatorFactory = () => validator.IsOptional;
export const IsDefined: ValidatorFactory = () => validator.IsDefined;
export const IsUnique: ValidatorFactory<[string]> = (name) => (options) =>
MutuallyExclusive(name, options);
export const IsSingleValue: ValidatorFactory = () => (options) =>
applyDecorators(Expose(options), validator.IsString(options));
export const IsMultiValue: ValidatorFactory = () => (options) =>
applyDecorators(
Expose(options),
validator.IsString({ ...options, each: true }),
);
export const IsURLValue: ValidatorFactory = () => (options) =>
applyDecorators(Expose(options), validator.IsUrl(undefined, options));
export const IsVersionValue: ValidatorFactory = () => (options) =>
applyDecorators(Expose(options), validator.IsSemVer(options));
export const IsSwitchValue: ValidatorFactory = () => (options) =>
applyDecorators(Expose(options), validator.IsBoolean(options));
export const IsNamedValue: ValidatorFactory = () => (options) =>
applyDecorators(
Expose(options),
IsRecord([validator.isString], [validator.isString], {
...options,
message: ({ property }) => ` "${property}" is not a valid named value`,
}),
);
export const IsEnumValue: ValidatorFactory<[Record<string, unknown>]> =
(entity) => (options) =>
applyDecorators(Expose(options), validator.IsEnum(entity, options));
export const IsNestedValue: ValidatorFactory<[{ new (): object }]> =
(clazz) => (options) =>
applyDecorators(
Expose(options),
transformer.Type(() => clazz),
validator.ValidateNested(options),
);
================================================
FILE: lib/fs.ts
================================================
/**
* FS-implementation aware functions.
* @module
*/
import path from 'path';
import { promisify } from 'util';
export interface Stats {
isFile: () => boolean;
isDirectory: () => boolean;
}
export interface FsStat {
stat(path: string, callback: (err: Error | null, stats: Stats) => void): void;
}
export interface FsReadFile {
readFile(
path: string,
callback: (err: Error | null, content: Buffer) => void,
): void;
}
export interface FsWriteFile {
writeFile(
path: string,
data: string | Buffer,
callback: (err: Error | null) => void,
): void;
}
export interface FsMkdir {
mkdir(
path: string,
options: { recursive?: boolean },
callback: (err: Error | null, path?: string) => void,
): void;
}
export async function findPackage(cwd: string, fs: FsStat): Promise<string> {
const statAsync = promisify(fs.stat);
let dir = cwd;
while (true) {
const parent = path.dirname(dir);
try {
const pkg = await statAsync(path.join(dir, 'package.json'));
if (pkg.isFile()) {
return dir;
}
} catch (e) {
// root directory
if (dir === parent) {
throw new Error(`package.json is not found`);
}
}
dir = parent;
}
}
export async function readJSON<T>(file: string, fs: FsReadFile): Promise<T> {
const readFileAsync = promisify(fs.readFile);
const buf = await readFileAsync(file);
return JSON.parse(buf.toString('utf-8'));
}
export async function writeJSON(
file: string,
data: unknown,
fs: FsWriteFile,
): Promise<void> {
const writeFileAsync = promisify(fs.writeFile);
await writeFileAsync(file, Buffer.from(JSON.stringify(data), 'utf-8'));
}
export async function mkdirp(
dir: string,
fs: FsMkdir,
): Promise<string | undefined> {
const mkdirAsync = promisify(fs.mkdir);
return await mkdirAsync(dir, { recursive: true });
}
================================================
FILE: lib/index.ts
================================================
import 'reflect-metadata';
import { UserscriptPlugin } from './plugin';
export default UserscriptPlugin;
export {
Feature,
HeaderClass,
HeadersProvider,
LoadHeadersOptions,
ProxyScriptFeatureOptions,
ProxyScriptOptions,
RenderHeadersOptions,
ResolveBaseURLsOptions,
SSRIAlgorithm,
SSRIFeatureOptions,
SSRIOptions,
SSRITag,
URLFilter,
ValidateHeadersOptions,
} from './features';
export * from './plugin';
export * from './types';
================================================
FILE: lib/plugin.ts
================================================
import path from 'node:path';
import {
AsyncParallelHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook,
} from 'tapable';
import { Compilation, Compiler, sources, WebpackPluginInstance } from 'webpack';
import { DEFAULT_LOCALE_KEY } from './const';
import {
Feature,
FixTags,
Interpolater,
LoadHeaders,
LoadHeadersOptions,
ProcessProxyScript,
ProcessSSRI,
ProxyScriptOptions,
RenderHeaders,
RenderHeadersOptions,
ResolveBaseURLs,
ResolveBaseURLsOptions,
SetDefaultTags,
SSRIOptions,
ValidateHeaders,
ValidateHeadersOptions,
} from './features';
import {
CompilationContext,
FileInfo,
HeadersProps,
UserscriptPluginInstance,
WaterfallContext,
} from './types';
import { date } from './utils';
const { ConcatSource, RawSource } = sources;
export interface UserscriptPluginOptions {
metajs?: boolean;
skip?: (fileInfo: FileInfo) => boolean;
proxyScript?: unknown;
i18n?: Record<string, unknown>;
}
export type UserscriptOptions = LoadHeadersOptions &
ResolveBaseURLsOptions &
SSRIOptions &
ProxyScriptOptions &
RenderHeadersOptions &
ValidateHeadersOptions &
UserscriptPluginOptions;
export class UserscriptPlugin
implements WebpackPluginInstance, UserscriptPluginInstance
{
public readonly name = 'UserscriptPlugin';
public readonly features: Feature[];
public readonly hooks = {
init: new AsyncParallelHook<[Compiler]>(['compiler']),
close: new AsyncParallelHook<[Compiler]>(['compiler']),
preprocess: new AsyncParallelHook<[Compilation, CompilationContext]>([
'compilation',
'context',
]),
process: new AsyncParallelHook<[Compilation, CompilationContext]>([
'compilation',
'context',
]),
headers: new AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>([
'headersProps',
'context',
]),
proxyHeaders: new AsyncSeriesWaterfallHook<
[HeadersProps, WaterfallContext]
>(['headersProps', 'context']),
proxyScriptFile: new AsyncSeriesWaterfallHook<[string, WaterfallContext]>([
'proxyScriptFile',
'context',
]),
renderHeaders: new AsyncSeriesBailHook<Map<string, HeadersProps>, string>([
'headersProps',
]),
renderProxyHeaders: new AsyncSeriesBailHook<HeadersProps, string>([
'headersProps',
]),
};
private readonly contexts = new WeakMap<Compilation, CompilationContext>();
private options: UserscriptPluginOptions = {};
public constructor(options: UserscriptOptions = {}) {
const { metajs = true, strict = true } = options;
Object.assign(options, { metajs, strict } as UserscriptOptions);
this.features = [
new LoadHeaders(options),
new FixTags(options),
new ResolveBaseURLs(options),
new ProcessSSRI(options),
new SetDefaultTags(options),
new ProcessProxyScript(options),
new Interpolater(options),
new ValidateHeaders(options),
new RenderHeaders(options),
];
this.options = options;
}
public apply(compiler: Compiler): void {
const name = this.name;
let buildNo = 0;
const initPromise = new Promise<void>((resolve) =>
queueMicrotask(() => resolve(this.init(compiler))),
);
compiler.hooks.beforeCompile.tapPromise(name, () => initPromise);
compiler.hooks.compilation.tap(name, (compilation) => {
this.contexts.set(compilation, {
buildNo: ++buildNo,
buildTime: date(),
fileInfo: [],
});
compilation.hooks.processAssets.tapPromise(
{
name,
stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,
},
() => this.preprocess(compilation),
);
compilation.hooks.processAssets.tapPromise(
{
name,
// we should generate userscript files
// only if optimization of source files are complete
stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
() => this.process(compilation),
);
});
compiler.hooks.done.tapPromise(name, () => this.close(compiler));
for (const feature of this.features) {
feature.apply(this);
}
}
private async init(compiler: Compiler): Promise<void> {
await this.hooks.init.promise(compiler);
}
private async close(compiler: Compiler): Promise<void> {
await this.hooks.close.promise(compiler);
}
private async preprocess(compilation: Compilation): Promise<void> {
const context = this.contexts.get(compilation);
/* istanbul ignore next */
if (!context) {
return;
}
context.fileInfo = this.collectFileInfo(compilation);
await this.hooks.preprocess.promise(compilation, context);
}
private async process(compilation: Compilation): Promise<void> {
const context = this.contexts.get(compilation);
/* istanbul ignore next */
if (!context) {
return;
}
await Promise.all(
context.fileInfo.map((fileInfo) =>
this.emitUserscript(compilation, context, fileInfo),
),
);
for (const { originalFile, userjsFile } of context.fileInfo) {
if (originalFile !== userjsFile) {
compilation.deleteAsset(originalFile);
}
}
await this.hooks.process.promise(compilation, context);
}
private collectFileInfo(compilation: Compilation): FileInfo[] {
const fileInfo: FileInfo[] = [];
for (const entrypoint of compilation.entrypoints.values()) {
const chunk = entrypoint.getEntrypointChunk();
for (const originalFile of chunk.files) {
let q = originalFile.indexOf('?');
if (q < 0) {
q = originalFile.length;
}
const filepath = originalFile.slice(0, q);
const query = originalFile.slice(q);
const dirname = path.dirname(filepath);
const filename = path.basename(filepath);
const basename = filepath.endsWith('.user.js')
? path.basename(filepath, '.user.js')
: filepath.endsWith('.js')
? path.basename(filepath, '.js')
: filepath;
const extname = path.extname(filepath);
const userjsFile = path.join(dirname, basename + '.user.js') + query;
const metajsFile = path.join(dirname, basename + '.meta.js');
const fileInfoEntry = {
chunk,
originalFile,
userjsFile,
metajsFile,
filename,
dirname,
basename,
query,
extname,
};
if (this.options.skip?.(fileInfoEntry) ?? extname !== '.js') {
continue;
}
fileInfo.push(fileInfoEntry);
}
}
return fileInfo;
}
private async emitUserscript(
compilation: Compilation,
context: CompilationContext,
fileInfo: FileInfo,
): Promise<void> {
const { metajs, proxyScript, i18n } = this.options;
const { originalFile, chunk, metajsFile, userjsFile } = fileInfo;
const sourceAsset = compilation.getAsset(originalFile);
const waterfall = {
...context,
fileInfo,
compilation,
};
if (!sourceAsset) {
/* istanbul ignore next */
return;
}
const localizedHeaders = new Map<string, HeadersProps>();
const headers = await this.hooks.headers.promise(
{},
{ ...waterfall, locale: DEFAULT_LOCALE_KEY },
);
localizedHeaders.set(DEFAULT_LOCALE_KEY, headers);
if (i18n) {
await Promise.all(
Object.keys(i18n).map(async (locale) => {
localizedHeaders.set(
locale,
await this.hooks.headers.promise({}, { ...waterfall, locale }),
);
}),
);
}
const headersStr = await this.hooks.renderHeaders.promise(localizedHeaders);
const proxyHeaders = proxyScript
? await this.hooks.proxyHeaders.promise(headers, {
...waterfall,
locale: DEFAULT_LOCALE_KEY,
})
: undefined;
const proxyScriptFile = proxyScript
? await this.hooks.proxyScriptFile.promise('', {
...waterfall,
locale: DEFAULT_LOCALE_KEY,
})
: undefined;
const proxyHeadersStr = proxyHeaders
? await this.hooks.renderProxyHeaders.promise(proxyHeaders)
: undefined;
if (userjsFile !== originalFile) {
compilation.emitAsset(
userjsFile,
new ConcatSource(headersStr, '\n', sourceAsset.source),
{
minimized: true,
},
);
chunk.files.add(userjsFile);
} else {
compilation.updateAsset(
userjsFile,
new ConcatSource(headersStr, '\n', sourceAsset.source),
{
minimized: true,
},
);
}
if (metajs !== false) {
compilation.emitAsset(metajsFile, new RawSource(headersStr), {
minimized: true,
});
chunk.auxiliaryFiles.add(metajsFile);
}
if (proxyScriptFile !== undefined && proxyHeadersStr !== undefined) {
compilation.emitAsset(proxyScriptFile, new RawSource(proxyHeadersStr), {
minimized: true,
});
chunk.auxiliaryFiles.add(proxyScriptFile);
}
}
}
================================================
FILE: lib/types.ts
================================================
import {
AsyncParallelHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook,
} from 'tapable';
import { Chunk, Compilation, Compiler } from 'webpack';
export type SingleValue = string | undefined;
export type MultiValue = SingleValue | SingleValue[];
export type NamedValue = Record<string, SingleValue>;
export type SwitchValue = boolean;
export type TagType = string;
export type ValueType =
| NamedValue
| MultiValue
| SingleValue
| SwitchValue
| undefined;
export type EnumValue<T extends string> = T | `${T}`;
export enum RunAt {
DocumentStart = 'document-start',
DocumentBody = 'document-body',
DocumentEnd = 'document-end',
DocumentIdle = 'document-idle',
ContextMenu = 'context-menu',
}
export enum Sandbox {
Raw = 'raw',
JavaScript = 'JavaScript',
DOM = 'DOM',
}
export enum InjectInto {
Page = 'page',
Content = 'content',
Auto = 'auto',
}
export interface CompatibilityValue extends NamedValue {
firefox?: string;
chrome?: string;
opera?: string;
safari?: string;
edge?: string;
}
export interface StrictHeadersProps {
name?: SingleValue;
version?: SingleValue;
namespace?: SingleValue;
author?: SingleValue;
description?: SingleValue;
homepage?: SingleValue;
homepageURL?: SingleValue;
website?: SingleValue;
source?: SingleValue;
icon?: SingleValue;
iconURL?: SingleValue;
defaulticon?: SingleValue;
icon64?: SingleValue;
icon64URL?: SingleValue;
updateURL?: SingleValue;
downloadURL?: SingleValue;
installURL?: SingleValue;
supportURL?: SingleValue;
include?: MultiValue;
match?: MultiValue;
'exclude-match'?: MultiValue;
exclude?: MultiValue;
require?: MultiValue;
resource?: NamedValue;
connect?: MultiValue;
grant?: MultiValue;
webRequest?: MultiValue;
noframes?: SwitchValue;
unwrap?: SwitchValue;
antifeature?: NamedValue;
'run-at'?: EnumValue<RunAt>;
copyright?: SingleValue;
sandbox?: EnumValue<Sandbox>;
'inject-into'?: EnumValue<InjectInto>;
license?: SingleValue;
contributionURL?: SingleValue;
contributionAmount?: SingleValue;
compatible?: CompatibilityValue;
incompatible?: CompatibilityValue;
}
export interface HeadersProps extends StrictHeadersProps {
[tag: TagType]: ValueType;
}
export interface FileInfo {
chunk: Chunk;
originalFile: string;
userjsFile: string;
metajsFile: string;
filename: string;
basename: string;
query: string;
dirname: string;
extname: string;
}
export interface CompilationContext {
buildNo: number;
buildTime: Date;
fileInfo: FileInfo[];
}
export interface WaterfallContext {
buildNo: number;
buildTime: Date;
fileInfo: FileInfo;
compilation: Compilation;
locale: string;
}
export interface UserscriptPluginInstance {
hooks: {
init: AsyncParallelHook<[Compiler]>;
close: AsyncParallelHook<[Compiler]>;
preprocess: AsyncParallelHook<[Compilation, CompilationContext]>;
process: AsyncParallelHook<[Compilation, CompilationContext]>;
headers: AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>;
proxyHeaders: AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>;
proxyScriptFile: AsyncSeriesWaterfallHook<[string, WaterfallContext]>;
renderHeaders: AsyncSeriesBailHook<Map<string, HeadersProps>, string>;
renderProxyHeaders: AsyncSeriesBailHook<HeadersProps, string>;
};
}
================================================
FILE: lib/utils.ts
================================================
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
export function date(): Date {
return new Date();
}
/**
* Shipped from NestJs#applyDecorators()
* @see {@link https://github.com/nestjs/nest/blob/bee462e031f9562210c65b9eb8e8a20cab1f301f/packages/common/decorators/core/apply-decorators.ts github:nestjs/nest}
*/
export function applyDecorators(
...decorators: Array<ClassDecorator | MethodDecorator | PropertyDecorator>
) {
return <TFunction extends (...args: any[]) => any, Y>(
target: TFunction | object,
propertyKey?: string | symbol,
descriptor?: TypedPropertyDescriptor<Y>,
): void => {
for (const decorator of decorators) {
if (target instanceof Function && !descriptor) {
(decorator as ClassDecorator)(target);
continue;
}
(decorator as MethodDecorator | PropertyDecorator)(
target,
propertyKey as string | symbol,
descriptor as TypedPropertyDescriptor<Y>,
);
}
};
}
/**
* @see {@link https://github.com/typestack/class-validator/issues/759#issuecomment-712361384 github:typestack/class-validator#759}
*/
export function MutuallyExclusive(
group: string,
validationOptions?: ValidationOptions,
): PropertyDecorator {
const key = MutuallyExclusive.getMetaKey(group);
return function (object: any, propertyName: string | symbol): void {
const existing = Reflect.getMetadata(key, object) ?? [];
Reflect.defineMetadata(key, [...existing, propertyName], object);
registerDecorator({
name: 'MutuallyExclusive',
target: object.constructor,
propertyName: propertyName as string,
constraints: [group],
options: validationOptions,
validator: {
validate(_: any, args: ValidationArguments) {
const mutuallyExclusiveProps: Array<string> = Reflect.getMetadata(
key,
args.object,
);
return (
mutuallyExclusiveProps.reduce(
(p, c) => ((args.object as any)[c] !== undefined ? ++p : p),
0,
) === 1
);
},
defaultMessage(validationArguments?: ValidationArguments) {
if (!validationArguments) {
return `Mutually exclusive group "${group}" is violated`;
}
const mutuallyExclusiveProps = (
Reflect.getMetadata(key, validationArguments.object) as string[]
).filter(
(prop) => (validationArguments.object as any)[prop] !== undefined,
);
const propsString = mutuallyExclusiveProps
.map((p) => `"${p}"`)
.join(', ');
return (
`Mutually exclusive group "${group}" is violated, ` +
`${propsString}.`
);
},
},
});
};
}
MutuallyExclusive.getMetaKey = (tag: string): symbol =>
Symbol.for(`custom:__@rst/validator_mutually_exclusive_${tag}__`);
export function IsRecord(
keyValidators: ((k: string | symbol) => boolean)[] = [],
valueValidators: ((v: any) => boolean)[] = [],
validationOptions?: ValidationOptions,
): PropertyDecorator {
return function (object: any, propertyName: string | symbol): void {
registerDecorator({
name: 'IsRecord',
target: object.constructor,
propertyName: propertyName as string,
constraints: [],
options: validationOptions,
validator: {
validate(value: any) {
return (
typeof value === 'object' &&
Object.entries(value).every(
([k, v]) =>
keyValidators.every((validator) => validator(k)) &&
valueValidators.every((validator) => validator(v)),
)
);
},
defaultMessage() {
return 'record validation failed';
},
},
});
};
}
================================================
FILE: package.json
================================================
{
"name": "webpack-userscript",
"version": "3.2.3",
"description": "A Webpack plugin for userscript projects.",
"repository": {
"type": "git",
"url": "git+https://github.com/momocow/webpack-userscript.git"
},
"author": "MomoCow <momocow.me@gmail.com>",
"keywords": [
"webpack",
"userscript",
"tampermonkey",
"greasemonkey"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/momocow/webpack-userscript/issues"
},
"homepage": "https://github.com/momocow/webpack-userscript#readme",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"lint": "eslint lib/ --ext .js,.jsx,.ts,.tsx",
"test": "jest --coverage --collectCoverageFrom \"lib/**/*.ts\"",
"test:dev": "jest",
"clean": "shx rm -rf dist",
"ts-node": "ts-node",
"docs": "typedoc",
"build": "tsc -p tsconfig.build.json",
"commit": "gitmoji -c",
"prepare": "husky install",
"release": "semantic-release"
},
"devDependencies": {
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^8.0.7",
"@semantic-release/npm": "^10.0.3",
"@types/express": "^4.17.17",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.2",
"@types/ssri": "^7.1.1",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0",
"eslint-import-resolver-typescript": "^3.5.3",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^10.0.0",
"express": "^4.18.2",
"gitmoji-cli": "^7.0.3",
"husky": "^8.0.3",
"jest": "29.4.1",
"jest-extended": "^3.2.3",
"lint-staged": "^13.1.0",
"memfs": "^3.4.13",
"prettier": "^2.8.3",
"semantic-release": "^21.0.1",
"semantic-release-gitmoji": "^1.6.4",
"shx": "^0.3.4",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1",
"typedoc": "^0.23.24",
"typescript": "^4.9.4",
"typescript-memoize": "^1.1.1",
"webpack": "^5.75.0"
},
"dependencies": {
"class-transformer": "0.5.1",
"class-validator": "^0.14.0",
"node-fetch": "^2.6.9",
"p-limit": "^3.1.0",
"reflect-metadata": "^0.1.13",
"ssri": "^10.0.1",
"table": "^6.8.1",
"tapable": "^2.2.1",
"tslib": "^2.0.0"
},
"peerDependencies": {
"webpack": "5"
}
}
================================================
FILE: test/integration/default-tags/fixtures.ts
================================================
import { GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
public static readonly defaultMatchValue = '*://*/*';
public static readonly httpsMatchValue = 'https://*/*';
public static readonly httpsIncludeValue = 'https://*';
public static readonly customValue = '__custom__';
}
================================================
FILE: test/integration/default-tags/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile, findTags } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('default tags', () => {
let input: Volume;
const httpsMatchTags = findTags.bind(
undefined,
'match',
Fixtures.httpsMatchValue,
);
const httpsIncludeTags = findTags.bind(
undefined,
'include',
Fixtures.httpsIncludeValue,
);
const defaultMatchTags = findTags.bind(
undefined,
'match',
Fixtures.defaultMatchValue,
);
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
// eslint-disable-next-line max-len
it('should use default match if no include or match specified', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [new UserscriptPlugin()],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
expect(defaultMatchTags(userJs)).toHaveLength(1);
expect(defaultMatchTags(metaJs)).toHaveLength(1);
});
it('should not use default match if include is provided', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
include: Fixtures.httpsIncludeValue,
},
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
expect(defaultMatchTags(userJs)).toHaveLength(0);
expect(httpsIncludeTags(userJs)).toHaveLength(1);
expect(defaultMatchTags(metaJs)).toHaveLength(0);
expect(httpsIncludeTags(metaJs)).toHaveLength(1);
});
it('should not use default match if match is provided', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
match: Fixtures.httpsMatchValue,
},
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
expect(defaultMatchTags(userJs)).toHaveLength(0);
expect(httpsMatchTags(userJs)).toHaveLength(1);
expect(defaultMatchTags(metaJs)).toHaveLength(0);
expect(httpsMatchTags(metaJs)).toHaveLength(1);
});
});
================================================
FILE: test/integration/fix-tags/fixtures.ts
================================================
import { File, GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
@File(__dirname, 'headers.txt')
public static readonly headers: string;
}
================================================
FILE: test/integration/fix-tags/headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @downloadURL http://example.com
// ==/UserScript==
================================================
FILE: test/integration/fix-tags/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('fix-tags', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('should fix tag names', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: { downloadUrl: 'http://example.com' },
strict: false,
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),
'/dist/output.meta.js': Fixtures.headers,
});
});
it('should throw on ambiguous tag names', () => {
const promise = compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
downloadUrl: 'http://1.example.com',
downloadURL: 'http://2.example.com',
},
strict: false,
}),
],
});
return expect(promise).toReject();
});
});
================================================
FILE: test/integration/fixtures/entry.js.txt
================================================
(function () {
console.log('hello');
})();
================================================
FILE: test/integration/fixtures/entry.min.js.txt
================================================
console.log("hello");
================================================
FILE: test/integration/fixtures/headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/fixtures/package.json.txt
================================================
{
"name": "userscript",
"version": "0.0.0",
"description": "this is a fantastic userscript",
"someUnrelatedProperty": true
}
================================================
FILE: test/integration/fixtures.ts
================================================
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { Configuration } from 'webpack';
export const FIXTURES_DIR = path.join(__dirname, 'fixtures');
export const File =
(...paths: string[]): PropertyDecorator =>
(target, prop) => {
Object.defineProperty(target, prop, {
value: readFileSync(path.join(...paths), 'utf-8'),
enumerable: true,
configurable: false,
writable: false,
});
};
export class GlobalFixtures {
@File(FIXTURES_DIR, 'entry.js.txt')
public static readonly entryJs: string;
@File(FIXTURES_DIR, 'entry.min.js.txt')
public static readonly entryMinJs: string;
@File(FIXTURES_DIR, 'headers.txt')
public static readonly headers: string;
@File(FIXTURES_DIR, 'package.json.txt')
public static readonly packageJson: string;
public static readonly webpackConfig: Configuration = Object.seal({
context: '/',
mode: 'production',
entry: '/entry.js',
output: {
path: '/dist',
filename: 'output.js',
},
});
public static entryUserJs(headers: string): string {
return headers + '\n' + this.entryMinJs;
}
}
================================================
FILE: test/integration/general/fixtures.ts
================================================
import { GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {}
================================================
FILE: test/integration/general/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('general', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('should successfully compile with default options', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [new UserscriptPlugin()],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),
'/dist/output.meta.js': Fixtures.headers,
});
});
it('should skip files based on the skip option', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
skip: (): boolean => true,
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.js': Fixtures.entryMinJs,
});
});
// eslint-disable-next-line max-len
it('should successfully compile even the file extension is .user.js', async () => {
input.renameSync('/entry.js', '/entry.user.js');
const output = await compile(input, {
...Fixtures.webpackConfig,
entry: '/entry.user.js',
output: {
path: '/dist',
filename: 'output.user.js',
},
plugins: [new UserscriptPlugin()],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),
'/dist/output.meta.js': Fixtures.headers,
});
});
});
================================================
FILE: test/integration/headers/fixtures.ts
================================================
import { HeadersProps, ValueType } from 'webpack-userscript';
import { File, GlobalFixtures } from '../fixtures';
interface TagSample {
value: ValueType;
expect: string | string[];
}
interface TagCase {
validValues: TagSample[];
invalidValues: Omit<TagSample, 'expect'>[];
}
export class Fixtures extends GlobalFixtures {
public static readonly customValue = '__custom__';
@File(__dirname, 'pretty-headers.txt')
public static readonly prettyHeaders: string;
@File(__dirname, 'tag-order-headers.txt')
public static readonly tagOrderHeaders: string;
public static readonly tagSamples: Record<string, TagCase> = {
'run-at': {
validValues: [
{ value: 'document-start', expect: 'document-start' },
{ value: 'document-body', expect: 'document-body' },
{ value: 'document-end', expect: 'document-end' },
{ value: 'document-idle', expect: 'document-idle' },
{ value: 'context-menu', expect: 'context-menu' },
],
invalidValues: [{ value: 'a' }],
},
sandbox: {
validValues: [
{ value: 'raw', expect: 'raw' },
{ value: 'JavaScript', expect: 'JavaScript' },
{ value: 'DOM', expect: 'DOM' },
],
invalidValues: [{ value: 'a' }],
},
'inject-into': {
validValues: [
{ value: 'page', expect: 'page' },
{ value: 'content', expect: 'content' },
{ value: 'auto', expect: 'auto' },
],
invalidValues: [{ value: 'a' }],
},
compatible: {
validValues: [
{
value: {
firefox: 'compatible string',
chrome: 'compatible string',
opera: 'compatible string',
safari: 'compatible string',
edge: 'compatible string',
},
expect: [
'firefox compatible string',
'chrome compatible string',
'opera compatible string',
'safari compatible string',
'edge compatible string',
],
},
],
invalidValues: [{ value: { unknownBrowser: 'compatible string' } }],
},
incompatible: {
validValues: [
{
value: {
firefox: 'compatible string',
chrome: 'compatible string',
opera: 'compatible string',
safari: 'compatible string',
edge: 'compatible string',
},
expect: [
'firefox compatible string',
'chrome compatible string',
'opera compatible string',
'safari compatible string',
'edge compatible string',
],
},
],
invalidValues: [{ value: { unknownBrowser: 'incompatible string' } }],
},
};
public static readonly mutuallyExclusiveTags: HeadersProps[] = [
{
homepage: 'https://home.example.com',
homepageURL: 'https://home.example.com',
website: 'https://home.example.com',
source: 'https://home.example.com',
},
{
icon: 'https://icon.example.com',
iconURL: 'https://icon.example.com',
defaulticon: 'https://icon.example.com',
},
{
downloadURL: 'https://download.example.com',
installURL: 'https://install.example.com',
},
];
}
================================================
FILE: test/integration/headers/index.test.ts
================================================
import { UserscriptOptions, UserscriptPlugin } from 'webpack-userscript';
import { compile, findTags } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('headers', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
describe('validate-headers', () => {
describe('strict & whiltelist', () => {
const testCustomTags =
(
count: number,
{
strict,
whitelist,
}: Pick<UserscriptOptions, 'strict' | 'whitelist'> = {},
) =>
async (): Promise<void> => {
const customTags = findTags.bind(
undefined,
'custom',
Fixtures.customValue,
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
custom: Fixtures.customValue,
},
strict,
whitelist,
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
expect(customTags(userJs)).toHaveLength(count);
expect(customTags(metaJs)).toHaveLength(count);
};
it('should throw for custom tags in strict but non-whitelist mode', () =>
expect(
testCustomTags(0, {
strict: true,
whitelist: false,
})(),
).toReject());
it(
'should render custom tags in non-strict and non-whitelist mode',
testCustomTags(1, {
strict: false,
whitelist: false,
}),
);
it(
'should not render custom tags in non-strict but whitelist mode',
testCustomTags(0, {
strict: false,
whitelist: true,
}),
);
it(
'should not render custom tags in strict and whitelist mode',
testCustomTags(0, {
strict: true,
whitelist: true,
}),
);
});
describe('headersClass', () => {
it('should use custom headersClass if provided', () => {
class EmptyHeaders {}
const promise = compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
custom: Fixtures.customValue,
},
headersClass: EmptyHeaders,
strict: true,
whitelist: false,
}),
],
});
return expect(promise).toReject();
});
for (const headers of Fixtures.mutuallyExclusiveTags) {
it(
'should throw error if mutually exclusive tags present: ' +
Object.keys(headers).join(', '),
() => {
const promise = compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers,
strict: true,
}),
],
});
return expect(promise).toReject();
},
);
}
for (const [tag, { validValues, invalidValues }] of Object.entries(
Fixtures.tagSamples,
)) {
describe(tag, () => {
for (const { value, expect: expectedOutput } of validValues) {
it('valid case: ' + JSON.stringify(value), async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
[tag]: value,
},
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
const expectedOutputList = !Array.isArray(expectedOutput)
? [expectedOutput]
: expectedOutput;
for (const outputValue of expectedOutputList) {
expect(findTags(tag, outputValue, userJs)).toHaveLength(1);
expect(findTags(tag, outputValue, metaJs)).toHaveLength(1);
}
});
}
for (const { value } of invalidValues) {
it('invalid case: ' + JSON.stringify(value), () => {
const promise = compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
[tag]: value,
},
}),
],
});
return expect(promise).toReject();
});
}
});
}
});
});
describe('render-headers', () => {
it('should be rendered prettily', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
resource: {
test: 'http://example.com/demo.jpg',
},
include: ['https://example.com/', 'http://example.com/'],
noframes: true,
unwrap: false,
},
pretty: true,
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.prettyHeaders),
'/dist/output.meta.js': Fixtures.prettyHeaders,
});
});
it('should respect the specified tag order', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
// though "@include" tag does not present in the headers,
// it is fine to be in the tagOrder list
tagOrder: ['include', 'match', 'version', 'description', 'name'],
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.tagOrderHeaders),
'/dist/output.meta.js': Fixtures.tagOrderHeaders,
});
});
});
});
================================================
FILE: test/integration/headers/pretty-headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @include https://example.com/
// @include http://example.com/
// @noframes
// @resource test http://example.com/demo.jpg
// ==/UserScript==
================================================
FILE: test/integration/headers/tag-order-headers.txt
================================================
// ==UserScript==
// @match *://*/*
// @version 0.0.0
// @description this is a fantastic userscript
// @name userscript
// ==/UserScript==
================================================
FILE: test/integration/i18n/fixtures.ts
================================================
import { File, GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
@File(__dirname, 'i18n.headers.txt')
public static readonly i18nHeaders: string;
@File(__dirname, 'non-strict-i18n.headers.txt')
public static readonly nonStrictI18nHeaders: string;
}
================================================
FILE: test/integration/i18n/i18n.headers.txt
================================================
// ==UserScript==
// @name i18n
// @name:en localized name
// @description this is a fantastic userscript
// @description:en i18n description
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/i18n/index.test.ts
================================================
import { HeadersProps, UserscriptPlugin } from 'webpack-userscript';
import { compile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('i18n', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('headers object', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'i18n',
},
i18n: {
en: {
name: 'localized name',
description: 'i18n description',
},
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),
'/dist/output.meta.js': Fixtures.i18nHeaders,
});
});
it('headers file', async () => {
input.writeFileSync(
'/headers.json',
JSON.stringify({
name: 'localized name',
description: 'i18n description',
}),
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'i18n',
},
i18n: {
en: '/headers.json',
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),
'/dist/output.meta.js': Fixtures.i18nHeaders,
});
});
it('headers provider', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'i18n',
},
i18n: {
en: (headers): HeadersProps => ({
...headers,
name: 'localized name',
description: 'i18n description',
}),
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),
'/dist/output.meta.js': Fixtures.i18nHeaders,
});
});
describe('unlocalizable tags', () => {
it('are rejected in strict mode', () => {
const promise = compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'i18n',
},
i18n: {
en: {
name: 'localized name',
downloadURL: 'https://example.com',
},
},
}),
],
});
return expect(promise).toReject();
});
it('are allowed in non-strict mode', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'non-strict i18n',
},
i18n: {
en: (headers): HeadersProps => ({
...headers,
downloadURL: 'https://example.com',
}),
},
strict: false,
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.nonStrictI18nHeaders,
),
'/dist/output.meta.js': Fixtures.nonStrictI18nHeaders,
});
});
it('are stripped in whitelist mode', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'i18n',
},
i18n: {
en: {
name: 'localized name',
description: 'i18n description',
// downloadURL will be stripped
downloadURL: 'https://example.com',
},
},
whitelist: true,
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders),
'/dist/output.meta.js': Fixtures.i18nHeaders,
});
});
});
});
================================================
FILE: test/integration/i18n/non-strict-i18n.headers.txt
================================================
// ==UserScript==
// @name non-strict i18n
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @downloadURL:en https://example.com
// ==/UserScript==
================================================
FILE: test/integration/interpolater/fixtures.ts
================================================
import { GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {}
================================================
FILE: test/integration/interpolater/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile, findTags } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
jest.mock('webpack-userscript/utils', () => ({
...jest.requireActual('webpack-userscript/utils'),
date: (): Date => new Date(0),
}));
describe('interpolater', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
for (const [name, expectedName] of [
['[name]', 'customEntry'],
['[file]', 'output.js'],
['[filename]', 'output.js'],
['[basename]', 'output'],
['[query]', ''],
['[dirname]', '.'],
['[buildNo]', '1'],
['[buildTime]', '1970-01-01T00:00:00.000Z'],
]) {
it(name, async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
entry: {
customEntry: '/entry.js',
},
plugins: [
new UserscriptPlugin({
headers: {
name,
},
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
expect(findTags('name', expectedName, userJs)).toHaveLength(1);
expect(findTags('name', expectedName, metaJs)).toHaveLength(1);
});
}
});
================================================
FILE: test/integration/load-headers/fixtures.ts
================================================
import { Memoize } from 'typescript-memoize';
import { File, GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
@Memoize()
public static get headersJson(): string {
return JSON.stringify({
name: 'load-headers',
});
}
@File(__dirname, 'load-headers.headers.txt')
public static readonly loadHeadersHeaders: string;
}
================================================
FILE: test/integration/load-headers/index.test.ts
================================================
import path from 'node:path';
import { HeadersProps, UserscriptPlugin } from 'webpack-userscript';
import * as fs from 'webpack-userscript/fs';
import { compile, watchCompile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('load-headers', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('can be loaded from headers object', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'load-headers',
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.loadHeadersHeaders),
'/dist/output.meta.js': Fixtures.loadHeadersHeaders,
});
});
describe('headers file', () => {
it('can be loaded from headers file', async () => {
input.writeFileSync('/headers.json', Fixtures.headersJson);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: '/headers.json',
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.loadHeadersHeaders,
),
'/dist/output.meta.js': Fixtures.loadHeadersHeaders,
});
});
// eslint-disable-next-line max-len
it('should throw error if headers file is not in .json format', async () => {
input.writeFileSync('/headers.json', '{"name": "invalid-json",');
const promise = compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: '/headers.json',
}),
],
});
await expect(promise).toReject();
});
// eslint-disable-next-line max-len
it('should reuse from headers file if the file is not changed', async () => {
input.writeFileSync('/headers.json', Fixtures.headersJson);
const plugin = new UserscriptPlugin({
headers: './headers.json',
});
const readJSONSpy = jest.spyOn(fs, 'readJSON');
const entry = './entry.js';
let step = 0;
let inputFullPath = '';
await watchCompile(
input,
{
...Fixtures.webpackConfig,
context: '/',
entry,
plugins: [plugin],
},
async ({ output, writeFile, cwd }) => {
switch (++step) {
case 1:
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.loadHeadersHeaders,
),
'/dist/output.meta.js': Fixtures.loadHeadersHeaders,
});
await writeFile(entry, Fixtures.entryJs);
break;
case 2:
break;
default:
fail('invalid steps');
}
inputFullPath = cwd;
return step < 2;
},
);
if (step !== 2) {
fail('invalid steps');
}
const headersJsonPath = path.join(inputFullPath, 'headers.json');
// headers.json has only been read once
expect(
readJSONSpy.mock.calls.reduce(
(count, call) => (call[0] === headersJsonPath ? ++count : count),
0,
),
).toEqual(1);
readJSONSpy.mockRestore();
});
});
it('should compile if package.json does not exist', async () => {
input.rmSync('/package.json');
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
name: 'userscript',
version: '0.0.0',
description: 'this is a fantastic userscript',
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),
'/dist/output.meta.js': Fixtures.headers,
});
});
describe('headers provider', () => {
it('can be loaded from headers provider function', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: (headers): HeadersProps => ({
...headers,
name: 'load-headers',
}),
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.loadHeadersHeaders,
),
'/dist/output.meta.js': Fixtures.loadHeadersHeaders,
});
});
it('can be loaded from async headers provider function', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: async (headers): Promise<HeadersProps> => ({
...headers,
name: 'load-headers',
}),
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.loadHeadersHeaders,
),
'/dist/output.meta.js': Fixtures.loadHeadersHeaders,
});
});
});
});
================================================
FILE: test/integration/load-headers/load-headers.headers.txt
================================================
// ==UserScript==
// @name load-headers
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/multi-entry/entry1.headers.txt
================================================
// ==UserScript==
// @name entry1
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/multi-entry/entry2.headers.txt
================================================
// ==UserScript==
// @name entry2
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/multi-entry/entry3.headers.txt
================================================
// ==UserScript==
// @name entry3
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/multi-entry/fixtures.ts
================================================
import { File, GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
@File(__dirname, 'entry1.headers.txt')
public static readonly entry1Headers: string;
@File(__dirname, 'entry2.headers.txt')
public static readonly entry2Headers: string;
@File(__dirname, 'entry3.headers.txt')
public static readonly entry3Headers: string;
}
================================================
FILE: test/integration/multi-entry/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('multi-entry', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry1.js': Fixtures.entryJs,
'/entry2.js': Fixtures.entryJs,
'/entry3.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('should call headers provider against each entry', async () => {
const headersProvider = jest.fn().mockImplementation(
(
headers,
{
fileInfo: {
chunk: { name },
},
},
) => ({ ...headers, name }),
);
const output = await compile(input, {
...Fixtures.webpackConfig,
entry: {
entry1: '/entry1.js',
entry2: '/entry2.js',
entry3: '/entry3.js',
},
output: {
path: '/dist',
filename: '[name].js',
},
plugins: [
new UserscriptPlugin({
headers: headersProvider,
}),
],
});
expect(headersProvider).toBeCalledTimes(3);
expect(output.toJSON()).toEqual({
'/dist/entry1.user.js': Fixtures.entryUserJs(Fixtures.entry1Headers),
'/dist/entry1.meta.js': Fixtures.entry1Headers,
'/dist/entry2.user.js': Fixtures.entryUserJs(Fixtures.entry2Headers),
'/dist/entry2.meta.js': Fixtures.entry2Headers,
'/dist/entry3.user.js': Fixtures.entryUserJs(Fixtures.entry3Headers),
'/dist/entry3.meta.js': Fixtures.entry3Headers,
});
});
it('should interpolate headers against each entry', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
entry: {
entry1: '/entry1.js',
entry2: '/entry2.js',
entry3: '/entry3.js',
},
output: {
path: '/dist',
filename: '[name].js',
},
plugins: [
new UserscriptPlugin({
headers: {
name: '[name]',
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/entry1.user.js': Fixtures.entryUserJs(Fixtures.entry1Headers),
'/dist/entry1.meta.js': Fixtures.entry1Headers,
'/dist/entry2.user.js': Fixtures.entryUserJs(Fixtures.entry2Headers),
'/dist/entry2.meta.js': Fixtures.entry2Headers,
'/dist/entry3.user.js': Fixtures.entryUserJs(Fixtures.entry3Headers),
'/dist/entry3.meta.js': Fixtures.entry3Headers,
});
});
});
================================================
FILE: test/integration/package-json/bugs.headers.txt
================================================
// ==UserScript==
// @name package-json-bugs
// @supportURL https://bugs.example.com/
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/package-json/fixtures.ts
================================================
import { File, GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
@File(__dirname, './root-option.headers.txt')
public static readonly rootOptionHeaders: string;
@File(__dirname, './bugs.headers.txt')
public static readonly bugsHeaders: string;
}
================================================
FILE: test/integration/package-json/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('package-json', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
});
});
it('should find package.json using root option', async () => {
input.mkdirpSync('/some/deep/deep/dir');
input.writeFileSync(
'/some/deep/deep/dir/package.json',
JSON.stringify({
name: 'package-json-deep',
version: '0.0.1',
description: 'description of package-json-deep',
author: 'author of package-json-deep',
homepage: 'https://homepage.package-json-deep.com/',
}),
);
input.writeFileSync(
'/package.json',
JSON.stringify({
name: 'package-json',
version: '0.0.0',
description: 'description of package-json',
author: 'author of package-json',
homepage: 'https://homepage.package-json.com/',
}),
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
root: '/some/deep/deep/dir/',
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.rootOptionHeaders),
'/dist/output.meta.js': Fixtures.rootOptionHeaders,
});
});
describe('various types of "bugs" in package.json', () => {
const bugsValues = [
'https://bugs.example.com/',
{ url: 'https://bugs.example.com/' },
];
for (const bugs of bugsValues) {
it('should parse "bugs" into "supportURL"', async () => {
input.writeFileSync(
'/package.json',
JSON.stringify({ name: 'package-json-bugs', bugs }),
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [new UserscriptPlugin({})],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.bugsHeaders),
'/dist/output.meta.js': Fixtures.bugsHeaders,
});
});
}
});
});
================================================
FILE: test/integration/package-json/root-option.headers.txt
================================================
// ==UserScript==
// @name package-json-deep
// @description description of package-json-deep
// @version 0.0.1
// @author author of package-json-deep
// @homepage https://homepage.package-json-deep.com/
// @match *://*/*
// ==/UserScript==
================================================
FILE: test/integration/proxy-script/base-url-proxy-script.headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @downloadURL http://example.com
// @updateURL http://example.com
// ==/UserScript==
================================================
FILE: test/integration/proxy-script/base-url-proxy-script.proxy-headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @require http://base.example.com/output.user.js
// ==/UserScript==
================================================
FILE: test/integration/proxy-script/fixtures.ts
================================================
import { File, GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
@File(__dirname, 'proxy-script.headers.txt')
public static readonly proxyScriptHeaders: string;
@File(__dirname, 'proxy-script.proxy-headers.txt')
public static readonly proxyScriptProxyHeaders: string;
@File(__dirname, 'base-url-proxy-script.proxy-headers.txt')
public static readonly baseURLProxyScriptProxyHeaders: string;
@File(__dirname, 'base-url-proxy-script.headers.txt')
public static readonly baseURLProxyScriptHeaders: string;
}
================================================
FILE: test/integration/proxy-script/index.test.ts
================================================
import { UserscriptPlugin } from 'webpack-userscript';
import { compile } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('proxy script', () => {
let input: Volume;
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('should generate proxy script at default location', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
// these URLs should be ignored in proxy scripts
updateURL: 'http://example.com',
installURL: 'http://example.com',
// require tag will be extended in the proxy script
require: ['http://require.example.com'],
},
proxyScript: {},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.proxy.user.js': Fixtures.proxyScriptProxyHeaders,
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.proxyScriptHeaders),
'/dist/output.meta.js': Fixtures.proxyScriptHeaders,
});
});
it('should generate proxy script at specified location', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
// these URLs should be ignored in proxy scripts
updateURL: 'http://example.com',
installURL: 'http://example.com',
// require tag will be extended in the proxy script
require: 'http://require.example.com',
},
proxyScript: {
filename: 'custom.proxy.user.js',
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/custom.proxy.user.js': Fixtures.proxyScriptProxyHeaders,
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.proxyScriptHeaders),
'/dist/output.meta.js': Fixtures.proxyScriptHeaders,
});
});
it('should generate proxy script with custom baseURL of userjs', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
// these URLs should be ignored in proxy scripts
updateURL: 'http://example.com',
downloadURL: 'http://example.com',
},
proxyScript: {
baseURL: 'http://base.example.com',
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.proxy.user.js': Fixtures.baseURLProxyScriptProxyHeaders,
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.baseURLProxyScriptHeaders,
),
'/dist/output.meta.js': Fixtures.baseURLProxyScriptHeaders,
});
});
});
================================================
FILE: test/integration/proxy-script/proxy-script.headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @installURL http://example.com
// @require http://require.example.com
// @updateURL http://example.com
// ==/UserScript==
================================================
FILE: test/integration/proxy-script/proxy-script.proxy-headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @require http://require.example.com
// @require http://localhost:8080/output.user.js
// ==/UserScript==
================================================
FILE: test/integration/resolve-base-urls/fixtures.ts
================================================
import { GlobalFixtures } from '../fixtures';
export class Fixtures extends GlobalFixtures {
public static readonly downloadURLWithUserjs =
'http://download.example.com/output.user.js';
public static readonly downloadURLWithMetajs =
'http://download.example.com/output.meta.js';
public static readonly updateURLWithMetajs =
'http://update.example.com/output.meta.js';
public static readonly updateURLWithUserjs =
'http://update.example.com/output.user.js';
}
================================================
FILE: test/integration/resolve-base-urls/index.test.ts
================================================
import { URL } from 'node:url';
import { UserscriptPlugin } from 'webpack-userscript';
import { compile, findTags } from '../util';
import { Volume } from '../volume';
import { Fixtures } from './fixtures';
describe('resolve base urls', () => {
let input: Volume;
const findDownloadURL = findTags.bind(
undefined,
'downloadURL',
Fixtures.downloadURLWithUserjs,
);
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('should resolve downloadURL and updateURL', async () => {
const findUpdateURLByMetajs = findTags.bind(
undefined,
'updateURL',
Fixtures.updateURLWithMetajs,
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
downloadBaseURL: new URL('http://download.example.com'),
updateBaseURL: 'http://update.example.com',
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
const metaJs = output
.readFileSync('/dist/output.meta.js')
.toString('utf-8');
expect(findDownloadURL(userJs)).toHaveLength(1);
expect(findDownloadURL(metaJs)).toHaveLength(1);
expect(findUpdateURLByMetajs(userJs)).toHaveLength(1);
expect(findUpdateURLByMetajs(metaJs)).toHaveLength(1);
});
it('should resolve updateURL with updateBaseURL and userjs', async () => {
const findUpdateURLByUserjs = findTags.bind(
undefined,
'updateURL',
Fixtures.updateURLWithUserjs,
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
downloadBaseURL: new URL('http://download.example.com'),
updateBaseURL: 'http://update.example.com',
metajs: false,
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
expect(findDownloadURL(userJs)).toHaveLength(1);
expect(findUpdateURLByUserjs(userJs)).toHaveLength(1);
});
it('should resolve updateURL by downloadBaseURL and userjs', async () => {
const findUpdateURLByDownloadURL = findTags.bind(
undefined,
'updateURL',
Fixtures.downloadURLWithUserjs,
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
downloadBaseURL: new URL('http://download.example.com'),
metajs: false,
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
expect(findDownloadURL(userJs)).toHaveLength(1);
expect(findUpdateURLByDownloadURL(userJs)).toHaveLength(1);
});
it('should resolve updateURL by downloadBaseURL and metajs', async () => {
const findUpdateURLByDownloadURL = findTags.bind(
undefined,
'updateURL',
Fixtures.downloadURLWithMetajs,
);
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
downloadBaseURL: new URL('http://download.example.com'),
metajs: true,
}),
],
});
const userJs = output
.readFileSync('/dist/output.user.js')
.toString('utf-8');
expect(findDownloadURL(userJs)).toHaveLength(1);
expect(findUpdateURLByDownloadURL(userJs)).toHaveLength(1);
});
});
================================================
FILE: test/integration/ssri/algorithms-ssri-headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68=
// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40=
// ==/UserScript==
================================================
FILE: test/integration/ssri/algorithms-ssri-lock.json.txt
================================================
{"http://localhost:${PORT}/travis-webpack-userscript.svg":"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40=","http://localhost:${PORT}/jquery-3.4.1.min.js":"sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68="}
================================================
FILE: test/integration/ssri/filters-ssri-headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==
// @require http://example.com/example.txt
// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg
// ==/UserScript==
================================================
FILE: test/integration/ssri/filters-ssri-lock.json.txt
================================================
{"http://localhost:${PORT}/jquery-3.4.1.min.js":"sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug=="}
================================================
FILE: test/integration/ssri/fixtures.ts
================================================
import path from 'node:path';
import { Memoize } from 'typescript-memoize';
import { File, GlobalFixtures } from '../fixtures';
import { template } from '../util';
export class Fixtures extends GlobalFixtures {
@File(path.join(__dirname, 'ssri-lock.json.txt'))
private static readonly _ssriLockJson: string;
public static get ssriLockJson(): (data: any) => string {
return template(this._ssriLockJson);
}
@File(path.join(__dirname, 'ssri-headers.txt'))
private static readonly _ssriHeaders: string;
@Memoize()
public static get ssriHeaders(): (data: any) => string {
return template(this._ssriHeaders);
}
@File(path.join(__dirname, 'filters-ssri-headers.txt'))
private static readonly _filtersSSRIHeaders: string;
@Memoize()
public static get filtersSSRIHeaders(): (data: any) => string {
return template(this._filtersSSRIHeaders);
}
@File(path.join(__dirname, 'filters-ssri-lock.json.txt'))
private static readonly _filtersSSRILockJson: string;
@Memoize()
public static get filtersSSRILockJson(): (data: any) => string {
return template(this._filtersSSRILockJson);
}
@File(path.join(__dirname, 'algorithms-ssri-headers.txt'))
private static readonly _algorithmsSSRIHeaders: string;
@Memoize()
public static get algorithmsSSRIHeaders(): (data: any) => string {
return template(this._algorithmsSSRIHeaders);
}
@File(path.join(__dirname, 'algorithms-ssri-lock.json.txt'))
private static readonly _algorithmsSSRILockJson: string;
@Memoize()
public static get algorithmsSSRILockJson(): (data: any) => string {
return template(this._algorithmsSSRILockJson);
}
@File(__dirname, 'multi-algo-ssri-lock.json.txt')
private static readonly _multiAlgorithmsSSRILockJson: string;
@Memoize()
public static get multiAlgorithmsSSRILockJson(): (data: any) => string {
return template(this._multiAlgorithmsSSRILockJson);
}
@File(__dirname, 'unsupported-protocols.headers.txt')
public static readonly _unsupportedProtocolsHeaders: string;
@Memoize()
public static get unsupportedProtocolsHeaders(): (data: any) => string {
return template(this._unsupportedProtocolsHeaders);
}
}
================================================
FILE: test/integration/ssri/index.test.ts
================================================
import path from 'node:path';
import fetch from 'node-fetch';
import { UserscriptPlugin } from 'webpack-userscript';
import { compile, readJSON, servceStatic, ServeStatic } from '../util';
import { createFsFromVolume, Volume } from '../volume';
import { Fixtures } from './fixtures';
jest.mock('node-fetch', () =>
jest.fn(jest.requireActual('node-fetch') as typeof fetch),
);
describe('ssri', () => {
let input: Volume;
let server: ServeStatic;
let tplData: { PORT: string };
beforeAll(async () => {
server = await servceStatic(path.join(__dirname, 'static'));
tplData = {
PORT: String(server.port),
};
});
afterAll(async () => {
await server.close();
});
beforeEach(async () => {
input = Volume.fromJSON({
'/entry.js': Fixtures.entryJs,
'/package.json': Fixtures.packageJson,
});
});
it('should generate SSRIs and ssri-lock.json under the context', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
context: '/home',
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
ssri: {},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
}),
);
expect(readJSON(output, '/home/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.ssriLockJson(tplData)),
);
});
it('should generate SSRIs and ssri-lock.json under the root', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
context: '/home',
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
root: '/data',
ssri: {},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
}),
);
expect(readJSON(output, '/data/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.ssriLockJson(tplData)),
);
});
it('should generate ssri-lock in custom lockfile', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
context: '/home',
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
ssri: {
lock: '/some/deep/dir/custom-lock.json',
},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
}),
);
expect(readJSON(output, '/some/deep/dir/custom-lock.json')).toEqual(
JSON.parse(Fixtures.ssriLockJson(tplData)),
);
});
it('should apply url filters to determine SSRI target urls', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
require: [
`http://localhost:${server.port}/jquery-3.4.1.min.js`,
`http://example.com/example.txt`,
],
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
ssri: {
include: (_, url): boolean => url.hostname.includes('localhost'),
exclude: (tag, url): boolean =>
tag === 'resource' || !url.pathname.endsWith('.js'),
},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.filtersSSRIHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.filtersSSRIHeaders(tplData),
}),
);
expect(readJSON(output, '/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.filtersSSRILockJson(tplData)),
);
});
it('should not generate SSRIs for unsupported protocols', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
context: '/home',
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
'unsupported-url': 'ftp://example.com',
},
},
root: '/data',
ssri: {},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.unsupportedProtocolsHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.unsupportedProtocolsHeaders(tplData),
}),
);
expect(readJSON(output, '/data/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.ssriLockJson(tplData)),
);
});
it('should generate SSRIs based on provided algorithms', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
ssri: {
algorithms: ['sha256'],
},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.algorithmsSSRIHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.algorithmsSSRIHeaders(tplData),
}),
);
expect(readJSON(output, '/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.algorithmsSSRILockJson(tplData)),
);
});
it('should generate SSRIs without ssri-lock.json', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
ssri: {
lock: false,
},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
});
});
it('should generate SSRIs with existing ssri-lock.json', async () => {
input.mkdirpSync('/data');
input.writeFileSync('/data/ssri-lock.json', Fixtures.ssriLockJson(tplData));
const intermediateFileSystem = createFsFromVolume(new Volume());
const writeFileSpy = jest.spyOn(intermediateFileSystem, 'writeFile');
const output = await compile(
input,
{
...Fixtures.webpackConfig,
context: '/data',
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/jquery-3.4.1.min.js`,
resource: {
// eslint-disable-next-line max-len
'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`,
},
},
ssri: {},
}),
],
},
{
intermediateFileSystem,
},
);
expect(fetch).not.toBeCalled();
expect(writeFileSpy).not.toBeCalled();
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
// there is no ssri-lock.json in output FS
// since ssri-lock remains unchanged (no write happened)
});
writeFileSpy.mockRestore();
});
it('should generate SSRIs with existing SSRIs in headers', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
context: '/data',
plugins: [
new UserscriptPlugin({
headers: {
require:
`http://localhost:${server.port}/jquery-3.4.1.min.js` +
`#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` +
`T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`,
resource: {
'legacy-badge':
`http://localhost:${server.port}` +
`/travis-webpack-userscript.svg` +
`#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` +
`Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`,
},
},
ssri: {},
}),
],
});
expect(fetch).not.toBeCalled();
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
}),
);
expect(readJSON(output, '/data/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.ssriLockJson(tplData)),
);
});
it(
'should throw error if SSRIs mismatch between those from headers and ' +
'those from ssri-lock.json',
async () => {
input.mkdirpSync('/data');
// correct SSRIs are in ssri-lock.json
input.writeFileSync(
'/data/ssri-lock.json',
Fixtures.ssriLockJson(tplData),
);
const promise = compile(input, {
...Fixtures.webpackConfig,
context: '/data',
plugins: [
new UserscriptPlugin({
// I switch SSRIs of jquery-3.4.1.min.js
// and travis-webpack-userscript.svg
// to simulate a mismatch
headers: {
require:
`http://localhost:${server.port}/jquery-3.4.1.min.js` +
`#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` +
`Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`,
resource: {
'legacy-badge':
`http://localhost:${server.port}` +
`/travis-webpack-userscript.svg` +
`#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` +
`T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`,
},
},
ssri: {},
}),
],
});
expect(fetch).not.toBeCalled();
await expect(promise).toReject();
},
);
it('should compile if no urls are found', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
ssri: {},
}),
],
});
expect(output.toJSON()).toEqual({
'/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers),
'/dist/output.meta.js': Fixtures.headers,
});
});
it('should generate SSRIs against missing algorithms', async () => {
const output = await compile(input, {
...Fixtures.webpackConfig,
context: '/data',
plugins: [
new UserscriptPlugin({
headers: {
require:
`http://localhost:${server.port}/jquery-3.4.1.min.js` +
`#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` +
`T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`,
resource: {
'legacy-badge':
`http://localhost:${server.port}` +
`/travis-webpack-userscript.svg` +
`#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` +
`Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`,
},
},
ssri: {
algorithms: ['sha256'],
},
}),
],
});
expect(output.toJSON()).toEqual(
expect.objectContaining({
'/dist/output.user.js': Fixtures.entryUserJs(
Fixtures.ssriHeaders(tplData),
),
'/dist/output.meta.js': Fixtures.ssriHeaders(tplData),
}),
);
expect(readJSON(output, '/data/ssri-lock.json')).toEqual(
JSON.parse(Fixtures.multiAlgorithmsSSRILockJson(tplData)),
);
});
it('should throw if fetching sources falied', () => {
return expect(
compile(input, {
...Fixtures.webpackConfig,
plugins: [
new UserscriptPlugin({
headers: {
require: `http://localhost:${server.port}/not-exist.js`,
},
ssri: {},
}),
],
}),
).rejects.toThrow(/404 Not Found/);
});
});
================================================
FILE: test/integration/ssri/multi-algo-ssri-lock.json.txt
================================================
{"http://localhost:${PORT}/travis-webpack-userscript.svg":"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40= sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==","http://localhost:${PORT}/jquery-3.4.1.min.js":"sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68= sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug=="}
================================================
FILE: test/integration/ssri/ssri-headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==
// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==
// ==/UserScript==
================================================
FILE: test/integration/ssri/ssri-lock.json.txt
================================================
{"http://localhost:${PORT}/travis-webpack-userscript.svg":"sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==","http://localhost:${PORT}/jquery-3.4.1.min.js":"sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug=="}
================================================
FILE: test/integration/ssri/static/.eslintignore
================================================
./**/*
================================================
FILE: test/integration/ssri/unsupported-protocols.headers.txt
================================================
// ==UserScript==
// @name userscript
// @description this is a fantastic userscript
// @version 0.0.0
// @match *://*/*
// @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==
// @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==
// @resource unsupported-url ftp://example.com
// ==/UserScript==
================================================
FILE: test/integration/util.ts
================================================
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import express, { static as expressStatic } from 'express';
import { Compiler, Configuration, webpack } from 'webpack';
import { createFsFromVolume, Volume } from './volume';
export const GLOBAL_FIXTURES_DIR = path.join(__dirname, 'fixtures');
export interface CompilerFileSystems {
intermediateFileSystem?: Volume;
}
type IntermediateFileSystem = Compiler['intermediateFileSystem'];
export async function compile(
input: Volume,
config: Configuration,
{ intermediateFileSystem }: CompilerFileSystems = {},
): Promise<Volume> {
const output = new Volume();
const compiler = webpack(config);
compiler.inputFileSystem = createFsFromVolume(input);
compiler.outputFileSystem = createFsFromVolume(output);
compiler.intermediateFileSystem = (intermediateFileSystem ??
compiler.outputFileSystem) as IntermediateFileSystem;
const stats = await promisify(compiler.run.bind(compiler))();
await promisify(compiler.close.bind(compiler))();
if (stats?.hasErrors() || stats?.hasWarnings()) {
const details = stats.toJson();
if (details.errorsCount) {
console.error(details.errors);
}
if (details.warningsCount) {
console.error(details.warnings);
}
throw new Error('invalid fixtures');
}
return output;
}
export interface WatchStep {
cwd: string;
output: Volume;
writeFile: (
file: string,
body: string,
encode?: BufferEncoding,
) => Promise<void>;
}
export async function watchCompile(
input: Volume,
config: Configuration,
handle: (ctx: WatchStep) => Promise<boolean>,
): Promise<void> {
return new Promise<void>(async (resolve, reject) => {
const watchDir = await mkdtemp(
path.join(tmpdir(), 'webpack-userscript_test_'),
);
for (const [file, body] of Object.entries(input.toJSON())) {
if (body === null) {
continue;
}
await writeFile(path.join(watchDir, file), body, 'utf-8');
}
const output = new Volume();
const compiler = webpack({
...config,
context:
typeof config.context === 'string'
? path.join(watchDir, config.context)
: undefined,
});
compiler.outputFileSystem = createFsFromVolume(output);
const watching = compiler.watch({}, async (err, stats) => {
if (err) {
await close();
reject(err);
return;
}
if (stats?.hasErrors() || stats?.hasWarnings()) {
const details = stats.toJson();
if (details.errorsCount) {
console.error(details.errors);
}
if (details.warningsCount) {
console.error(details.warnings);
}
await close();
reject(new Error('invalid fixtures'));
return;
}
try {
const conti = await handle({
cwd: watchDir,
output,
writeFile: writeFileInWatchDir,
});
if (!conti) {
await close();
resolve();
}
} catch (err) {
await close();
reject(err);
}
});
const closeWatching = promisify(watching.close.bind(watching));
const closeCompiler = promisify(compiler.close.bind(compiler));
const close = async (): Promise<void> => {
await closeWatching();
await closeCompiler();
await rm(watchDir, { recursive: true, force: true });
};
const writeFileInWatchDir = (
file: string,
body: string,
encode?: BufferEncoding,
): Promise<void> => writeFile(path.join(watchDir, file), body, encode);
});
}
export interface ServeStatic {
port: number;
close: () => Promise<void>;
}
export async function servceStatic(root: string): Promise<ServeStatic> {
return new Promise((resolve, reject) => {
const app = express();
app.use(expressStatic(root));
const server = app
.listen(() => {
const { port } = server.address() as AddressInfo;
resolve({ port, close: promisify(server.close.bind(server)) });
})
.on('error', (err) => {
reject(err);
});
});
}
export function escapeRegex(str: string): string {
return str.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
}
export function findTags(
tag: string,
value: string,
content: string,
): string[] {
return (
content.match(
new RegExp(`// @${escapeRegex(tag)} ${escapeRegex(value)}\n`, 'g'),
) ?? []
);
}
export function template<T extends Record<string, string>>(
tpl: string,
): (data: T) => string {
return function (data: T): string {
return tpl.replace(
/\$\{([^}]+)\}/g,
(matched, matchedKey) => data[matchedKey] ?? matched,
);
};
}
export function readJSON(vol: Volume, file: string): unknown {
return JSON.parse(vol.readFileSync(file).toString('utf-8'));
}
================================================
FILE: test/integration/volume.ts
================================================
import { createFsFromVolume as memfsCreateFsFromVolume } from 'memfs';
import { Volume } from 'memfs/lib/volume';
import { InputFileSystem, OutputFileSystem } from 'webpack';
export const createFsFromVolume = memfsCreateFsFromVolume as unknown as (
...args: Parameters<typeof memfsCreateFsFromVolume>
) => Volume & InputFileSystem & OutputFileSystem;
export { Volume };
================================================
FILE: test/setup.ts
================================================
import 'jest-extended';
================================================
FILE: tsconfig.build.json
================================================
{
"extends": "./tsconfig.json",
"exclude": ["test", "jest.config.ts"]
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"baseUrl": ".",
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"importHelpers": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"sourceMap": true,
"outDir": "./dist/",
"types": ["node", "jest"],
"lib": ["ESNext"],
"emitDecoratorMetadata": true,
"paths": {
"webpack-userscript": ["lib"],
"webpack-userscript/*": ["lib/*"],
"class-transformer/cjs/storage": ["node_modules/class-transformer/types/storage"]
}
},
"include": ["**/*.ts"]
}
================================================
FILE: typedoc.json
================================================
{
"entryPoints": ["lib/index.ts"],
"out": "docs"
}
gitextract_s5o6o3m1/ ├── .eslintignore ├── .eslintrc.js ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── docs.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .lintstagedrc.json ├── .prettierrc ├── .releaserc.json ├── LICENSE ├── README.md ├── jest.config.ts ├── lib/ │ ├── const.ts │ ├── features/ │ │ ├── default-tags.ts │ │ ├── feature.ts │ │ ├── fix-tags.ts │ │ ├── index.ts │ │ ├── interpolater.ts │ │ ├── load-headers/ │ │ │ ├── impl.ts │ │ │ ├── index.ts │ │ │ └── loaders.ts │ │ ├── proxy-script.ts │ │ ├── render-headers.ts │ │ ├── resolve-base-urls.ts │ │ ├── ssri.ts │ │ └── validate-headers/ │ │ ├── headers.ts │ │ ├── impl.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── fs.ts │ ├── index.ts │ ├── plugin.ts │ ├── types.ts │ └── utils.ts ├── package.json ├── test/ │ ├── integration/ │ │ ├── default-tags/ │ │ │ ├── fixtures.ts │ │ │ └── index.test.ts │ │ ├── fix-tags/ │ │ │ ├── fixtures.ts │ │ │ ├── headers.txt │ │ │ └── index.test.ts │ │ ├── fixtures/ │ │ │ ├── entry.js.txt │ │ │ ├── entry.min.js.txt │ │ │ ├── headers.txt │ │ │ └── package.json.txt │ │ ├── fixtures.ts │ │ ├── general/ │ │ │ ├── fixtures.ts │ │ │ └── index.test.ts │ │ ├── headers/ │ │ │ ├── fixtures.ts │ │ │ ├── index.test.ts │ │ │ ├── pretty-headers.txt │ │ │ └── tag-order-headers.txt │ │ ├── i18n/ │ │ │ ├── fixtures.ts │ │ │ ├── i18n.headers.txt │ │ │ ├── index.test.ts │ │ │ └── non-strict-i18n.headers.txt │ │ ├── interpolater/ │ │ │ ├── fixtures.ts │ │ │ └── index.test.ts │ │ ├── load-headers/ │ │ │ ├── fixtures.ts │ │ │ ├── index.test.ts │ │ │ └── load-headers.headers.txt │ │ ├── multi-entry/ │ │ │ ├── entry1.headers.txt │ │ │ ├── entry2.headers.txt │ │ │ ├── entry3.headers.txt │ │ │ ├── fixtures.ts │ │ │ └── index.test.ts │ │ ├── package-json/ │ │ │ ├── bugs.headers.txt │ │ │ ├── fixtures.ts │ │ │ ├── index.test.ts │ │ │ └── root-option.headers.txt │ │ ├── proxy-script/ │ │ │ ├── base-url-proxy-script.headers.txt │ │ │ ├── base-url-proxy-script.proxy-headers.txt │ │ │ ├── fixtures.ts │ │ │ ├── index.test.ts │ │ │ ├── proxy-script.headers.txt │ │ │ └── proxy-script.proxy-headers.txt │ │ ├── resolve-base-urls/ │ │ │ ├── fixtures.ts │ │ │ └── index.test.ts │ │ ├── ssri/ │ │ │ ├── algorithms-ssri-headers.txt │ │ │ ├── algorithms-ssri-lock.json.txt │ │ │ ├── filters-ssri-headers.txt │ │ │ ├── filters-ssri-lock.json.txt │ │ │ ├── fixtures.ts │ │ │ ├── index.test.ts │ │ │ ├── multi-algo-ssri-lock.json.txt │ │ │ ├── ssri-headers.txt │ │ │ ├── ssri-lock.json.txt │ │ │ ├── static/ │ │ │ │ └── .eslintignore │ │ │ └── unsupported-protocols.headers.txt │ │ ├── util.ts │ │ └── volume.ts │ └── setup.ts ├── tsconfig.build.json ├── tsconfig.json └── typedoc.json
SYMBOL INDEX (159 symbols across 34 files)
FILE: jest.config.ts
constant EXCLUDE_PATHS (line 6) | const EXCLUDE_PATHS = new Set(['class-transformer/cjs/storage']);
FILE: lib/const.ts
constant DEFAULT_LOCALE_KEY (line 1) | const DEFAULT_LOCALE_KEY = '';
FILE: lib/features/default-tags.ts
class SetDefaultTags (line 5) | class SetDefaultTags extends Feature {
method apply (line 8) | public apply({ hooks }: UserscriptPluginInstance): void {
FILE: lib/features/feature.ts
method constructor (line 4) | public constructor(public readonly options: Options) {}
FILE: lib/features/fix-tags.ts
class FixTags (line 4) | class FixTags extends Feature {
method apply (line 17) | public apply({ hooks }: UserscriptPluginInstance): void {
FILE: lib/features/interpolater.ts
class Interpolater (line 9) | class Interpolater extends Feature {
method apply (line 12) | public apply({ hooks }: UserscriptPluginInstance): void {
method getVariables (line 26) | private getVariables({
method interpolate (line 60) | private interpolate(
FILE: lib/features/load-headers/impl.ts
type HeadersOption (line 17) | type HeadersOption = HeadersProps | HeadersFile | HeadersProvider;
type LoadHeadersOptions (line 19) | interface LoadHeadersOptions {
class LoadHeaders (line 25) | class LoadHeaders extends Feature<LoadHeadersOptions> {
method apply (line 33) | public apply({ hooks }: UserscriptPluginInstance): void {
method addLoader (line 65) | private addLoader(locale: string, headersOption: HeadersOption): void {
method provideHeaders (line 84) | private async provideHeaders(ctx: WaterfallContext): Promise<HeadersPr...
FILE: lib/features/load-headers/loaders.ts
class ObjectLoader (line 9) | class ObjectLoader {
method constructor (line 10) | public constructor(public headers: HeadersProps) {}
method load (line 12) | public load(): HeadersProps {
type HeadersFile (line 17) | type HeadersFile = string;
class FileLoader (line 19) | class FileLoader {
method constructor (line 24) | public constructor(private file: HeadersFile, private root?: string) {}
method load (line 26) | public async load(compilation: Compilation): Promise<HeadersProps> {
type HeadersProvider (line 64) | type HeadersProvider = (
class ProviderLoader (line 69) | class ProviderLoader {
method constructor (line 70) | public constructor(private provider: HeadersProvider) {}
method load (line 72) | public async load(
type PackageInfo (line 80) | interface PackageInfo {
class PackageLoader (line 89) | class PackageLoader {
method constructor (line 92) | public constructor(private root?: string) {}
method load (line 94) | public async load(compiler: Compiler): Promise<HeadersProps> {
FILE: lib/features/proxy-script.ts
type ProxyScriptFeatureOptions (line 6) | interface ProxyScriptFeatureOptions {
type ProxyScriptOptions (line 11) | interface ProxyScriptOptions {
class ProcessProxyScript (line 15) | class ProcessProxyScript extends Feature<ProxyScriptOptions> {
method apply (line 18) | public apply({ hooks }: UserscriptPluginInstance): void {
FILE: lib/features/render-headers.ts
type RenderHeadersOptions (line 12) | interface RenderHeadersOptions {
class RenderHeaders (line 20) | class RenderHeaders extends Feature<RenderHeadersOptions> {
method apply (line 23) | public apply({ hooks }: UserscriptPluginInstance): void {
method mergeHeadersMap (line 35) | private mergeHeadersMap(headersMap: Map<string, HeadersProps>): Header...
method render (line 49) | private render(
method renderTag (line 94) | protected renderTag(tag: TagType, value: ValueType): string[][] {
method getTagName (line 114) | private getTagName(tag: string): string {
FILE: lib/features/resolve-base-urls.ts
type ResolveBaseURLsOptions (line 6) | interface ResolveBaseURLsOptions {
class ResolveBaseURLs (line 12) | class ResolveBaseURLs extends Feature<ResolveBaseURLsOptions> {
method apply (line 15) | public apply({ hooks }: UserscriptPluginInstance): void {
FILE: lib/features/ssri.ts
type RawSSRILock (line 25) | type RawSSRILock = Record<string, string>;
type SSRILock (line 26) | type SSRILock = Record<string, IntegrityMap>;
type SSRIAlgorithm (line 28) | type SSRIAlgorithm = 'sha256' | 'sha384' | 'sha512';
type SSRITag (line 30) | type SSRITag = 'require' | 'resource';
type URLFilter (line 32) | type URLFilter = (tag: SSRITag, value: URL) => boolean;
type SSRIFeatureOptions (line 34) | interface SSRIFeatureOptions {
type SSRIOptions (line 43) | interface SSRIOptions {
class ProcessSSRI (line 48) | class ProcessSSRI extends Feature<SSRIOptions> {
method apply (line 62) | public apply({ hooks }: UserscriptPluginInstance): void {
method getSSRILockFile (line 182) | private getSSRILockFile({ lock = true }: SSRIFeatureOptions = {}):
method getTargetURLs (line 192) | private getTargetURLs(
method normalizeURL (line 225) | private normalizeURL(url: string): string {
method filterURL (line 232) | private filterURL(
method computeSSRI (line 252) | private async computeSSRI(
method parseSSRILike (line 272) | private parseSSRILike(url: string): string {
method parseSSRILock (line 276) | private parseSSRILock(rawSSRILock: RawSSRILock): SSRILock {
method toRawSSRILock (line 285) | private toRawSSRILock(ssriLock: SSRILock): RawSSRILock {
method parseURL (line 294) | private parseURL(url: string): URL {
method stringifyURL (line 298) | private stringifyURL(url: URL): string {
method updateURL (line 302) | private updateURL(url: string, ssriLock: SSRILock): string {
method patchHeaders (line 313) | private patchHeaders(
FILE: lib/features/validate-headers/headers.ts
type ValidationGroup (line 26) | enum ValidationGroup {
class Compatibility (line 35) | class Compatibility implements CompatibilityValue {
class Headers (line 54) | class Headers implements StrictHeadersProps {
FILE: lib/features/validate-headers/impl.ts
type HeadersValidatorOptions (line 9) | interface HeadersValidatorOptions {
type HeaderClass (line 14) | type HeaderClass = { new (): object };
type ValidateHeadersOptions (line 16) | interface ValidateHeadersOptions extends HeadersValidatorOptions {
class ValidateHeaders (line 21) | class ValidateHeaders extends Feature<ValidateHeadersOptions> {
method apply (line 24) | public apply({ hooks }: UserscriptPluginInstance): void {
method validateHeaders (line 38) | private validateHeaders(
FILE: lib/features/validate-headers/utils.ts
type GroupsOptions (line 7) | interface GroupsOptions {
type Validator (line 11) | type Validator = (options?: GroupsOptions) => PropertyDecorator;
type ValidatorFactory (line 12) | type ValidatorFactory<T extends any[] = []> = (...args: T) => Validator;
FILE: lib/fs.ts
type Stats (line 8) | interface Stats {
type FsStat (line 13) | interface FsStat {
type FsReadFile (line 17) | interface FsReadFile {
type FsWriteFile (line 24) | interface FsWriteFile {
type FsMkdir (line 32) | interface FsMkdir {
function findPackage (line 40) | async function findPackage(cwd: string, fs: FsStat): Promise<string> {
function readJSON (line 61) | async function readJSON<T>(file: string, fs: FsReadFile): Promise<T> {
function writeJSON (line 68) | async function writeJSON(
function mkdirp (line 77) | async function mkdirp(
FILE: lib/plugin.ts
type UserscriptPluginOptions (line 40) | interface UserscriptPluginOptions {
type UserscriptOptions (line 47) | type UserscriptOptions = LoadHeadersOptions &
class UserscriptPlugin (line 55) | class UserscriptPlugin
method constructor (line 95) | public constructor(options: UserscriptOptions = {}) {
method apply (line 114) | public apply(compiler: Compiler): void {
method init (line 157) | private async init(compiler: Compiler): Promise<void> {
method close (line 161) | private async close(compiler: Compiler): Promise<void> {
method preprocess (line 165) | private async preprocess(compilation: Compilation): Promise<void> {
method process (line 178) | private async process(compilation: Compilation): Promise<void> {
method collectFileInfo (line 201) | private collectFileInfo(compilation: Compilation): FileInfo[] {
method emitUserscript (line 248) | private async emitUserscript(
FILE: lib/types.ts
type SingleValue (line 8) | type SingleValue = string | undefined;
type MultiValue (line 9) | type MultiValue = SingleValue | SingleValue[];
type NamedValue (line 10) | type NamedValue = Record<string, SingleValue>;
type SwitchValue (line 11) | type SwitchValue = boolean;
type TagType (line 13) | type TagType = string;
type ValueType (line 14) | type ValueType =
type EnumValue (line 21) | type EnumValue<T extends string> = T | `${T}`;
type RunAt (line 23) | enum RunAt {
type Sandbox (line 31) | enum Sandbox {
type InjectInto (line 37) | enum InjectInto {
type CompatibilityValue (line 43) | interface CompatibilityValue extends NamedValue {
type StrictHeadersProps (line 51) | interface StrictHeadersProps {
type HeadersProps (line 93) | interface HeadersProps extends StrictHeadersProps {
type FileInfo (line 97) | interface FileInfo {
type CompilationContext (line 109) | interface CompilationContext {
type WaterfallContext (line 115) | interface WaterfallContext {
type UserscriptPluginInstance (line 123) | interface UserscriptPluginInstance {
FILE: lib/utils.ts
function date (line 7) | function date(): Date {
function applyDecorators (line 15) | function applyDecorators(
function MutuallyExclusive (line 40) | function MutuallyExclusive(
function IsRecord (line 99) | function IsRecord(
FILE: test/integration/default-tags/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/fix-tags/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/fixtures.ts
constant FIXTURES_DIR (line 6) | const FIXTURES_DIR = path.join(__dirname, 'fixtures');
class GlobalFixtures (line 19) | class GlobalFixtures {
method entryUserJs (line 42) | public static entryUserJs(headers: string): string {
FILE: test/integration/general/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {}
FILE: test/integration/headers/fixtures.ts
type TagSample (line 5) | interface TagSample {
type TagCase (line 10) | interface TagCase {
class Fixtures (line 15) | class Fixtures extends GlobalFixtures {
FILE: test/integration/headers/index.test.ts
class EmptyHeaders (line 93) | class EmptyHeaders {}
FILE: test/integration/i18n/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/interpolater/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {}
FILE: test/integration/load-headers/fixtures.ts
class Fixtures (line 5) | class Fixtures extends GlobalFixtures {
method headersJson (line 7) | public static get headersJson(): string {
FILE: test/integration/multi-entry/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/package-json/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/proxy-script/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/resolve-base-urls/fixtures.ts
class Fixtures (line 3) | class Fixtures extends GlobalFixtures {
FILE: test/integration/ssri/fixtures.ts
class Fixtures (line 8) | class Fixtures extends GlobalFixtures {
method ssriLockJson (line 12) | public static get ssriLockJson(): (data: any) => string {
method ssriHeaders (line 20) | public static get ssriHeaders(): (data: any) => string {
method filtersSSRIHeaders (line 28) | public static get filtersSSRIHeaders(): (data: any) => string {
method filtersSSRILockJson (line 36) | public static get filtersSSRILockJson(): (data: any) => string {
method algorithmsSSRIHeaders (line 44) | public static get algorithmsSSRIHeaders(): (data: any) => string {
method algorithmsSSRILockJson (line 52) | public static get algorithmsSSRILockJson(): (data: any) => string {
method multiAlgorithmsSSRILockJson (line 60) | public static get multiAlgorithmsSSRILockJson(): (data: any) => string {
method unsupportedProtocolsHeaders (line 68) | public static get unsupportedProtocolsHeaders(): (data: any) => string {
FILE: test/integration/util.ts
constant GLOBAL_FIXTURES_DIR (line 12) | const GLOBAL_FIXTURES_DIR = path.join(__dirname, 'fixtures');
type CompilerFileSystems (line 14) | interface CompilerFileSystems {
type IntermediateFileSystem (line 18) | type IntermediateFileSystem = Compiler['intermediateFileSystem'];
function compile (line 20) | async function compile(
type WatchStep (line 52) | interface WatchStep {
function watchCompile (line 62) | async function watchCompile(
type ServeStatic (line 149) | interface ServeStatic {
function servceStatic (line 154) | async function servceStatic(root: string): Promise<ServeStatic> {
function escapeRegex (line 169) | function escapeRegex(str: string): string {
function findTags (line 173) | function findTags(
function template (line 185) | function template<T extends Record<string, string>>(
function readJSON (line 196) | function readJSON(vol: Volume, file: string): unknown {
Condensed preview — 96 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (160K chars).
[
{
"path": ".eslintignore",
"chars": 4,
"preview": "*.js"
},
{
"path": ".eslintrc.js",
"chars": 1451,
"preview": "module.exports = {\n parser: '@typescript-eslint/parser',\n parserOptions: {\n project: './tsconfig.json',\n tsconfi"
},
{
"path": ".github/FUNDING.yml",
"chars": 168,
"preview": "# These are supported funding model platforms\n\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'l"
},
{
"path": ".github/workflows/docs.yaml",
"chars": 1099,
"preview": "name: Deploy docs to Pages\n\non:\n push:\n branches: ['main']\n\n workflow_dispatch:\n\n# Sets permissions of the GITHUB_T"
},
{
"path": ".github/workflows/release.yaml",
"chars": 675,
"preview": "name: Release\non:\n workflow_dispatch:\n push:\n branches:\n - main\n - alpha\n\njobs:\n release:\n name: Rele"
},
{
"path": ".github/workflows/test.yaml",
"chars": 418,
"preview": "name: Test\n\non:\n - push\n - pull_request\n\njobs:\n build:\n runs-on: ubuntu-latest\n\n strategy:\n matrix:\n "
},
{
"path": ".gitignore",
"chars": 905,
"preview": ".vscode/\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n"
},
{
"path": ".husky/pre-commit",
"chars": 95,
"preview": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\n# ignore errors\nnpx lint-staged || exit 0\n"
},
{
"path": ".lintstagedrc.json",
"chars": 54,
"preview": "{\n \"**/*.ts\": [\"prettier --write\", \"eslint --fix\"]\n}\n"
},
{
"path": ".prettierrc",
"chars": 72,
"preview": "{\n \"singleQuote\": true,\n \"trailingComma\": \"all\",\n \"printWidth\": 80\n}\n"
},
{
"path": ".releaserc.json",
"chars": 417,
"preview": "{\n \"branches\": [\n \"main\",\n {\n \"name\": \"alpha\",\n \"prerelease\": true\n }\n ],\n \"plugins\": [\n \"seman"
},
{
"path": "LICENSE",
"chars": 1087,
"preview": "MIT License\n\nCopyright (c) 2023 MomoCow <momocow.me@gmail.com>\n\nPermission is hereby granted, free of charge, to any per"
},
{
"path": "README.md",
"chars": 14227,
"preview": "# webpack-userscript\n\n[ {\n console.log('hello');\n})();\n"
},
{
"path": "test/integration/fixtures/entry.min.js.txt",
"chars": 21,
"preview": "console.log(\"hello\");"
},
{
"path": "test/integration/fixtures/headers.txt",
"chars": 140,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/fixtures/package.json.txt",
"chars": 132,
"preview": "{\n \"name\": \"userscript\",\n \"version\": \"0.0.0\",\n \"description\": \"this is a fantastic userscript\",\n \"someUnrelatedPrope"
},
{
"path": "test/integration/fixtures.ts",
"chars": 1138,
"preview": "import { readFileSync } from 'node:fs';\nimport path from 'node:path';\n\nimport { Configuration } from 'webpack';\n\nexport "
},
{
"path": "test/integration/general/fixtures.ts",
"chars": 95,
"preview": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {}\n"
},
{
"path": "test/integration/general/index.test.ts",
"chars": 1684,
"preview": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../vo"
},
{
"path": "test/integration/headers/fixtures.ts",
"chars": 3256,
"preview": "import { HeadersProps, ValueType } from 'webpack-userscript';\n\nimport { File, GlobalFixtures } from '../fixtures';\n\ninte"
},
{
"path": "test/integration/headers/index.test.ts",
"chars": 6603,
"preview": "import { UserscriptOptions, UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } from '../util';\n"
},
{
"path": "test/integration/headers/pretty-headers.txt",
"chars": 268,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @in"
},
{
"path": "test/integration/headers/tag-order-headers.txt",
"chars": 140,
"preview": "// ==UserScript==\n// @match *://*/*\n// @version 0.0.0\n// @description this is a fantastic userscript\n// @name userscript"
},
{
"path": "test/integration/i18n/fixtures.ts",
"chars": 293,
"preview": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n @File(__dirname, '"
},
{
"path": "test/integration/i18n/i18n.headers.txt",
"chars": 197,
"preview": "// ==UserScript==\n// @name i18n\n// @name:en localized name\n// @description this is a fantastic userscript\n// @descriptio"
},
{
"path": "test/integration/i18n/index.test.ts",
"chars": 4288,
"preview": "import { HeadersProps, UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume"
},
{
"path": "test/integration/i18n/non-strict-i18n.headers.txt",
"chars": 184,
"preview": "// ==UserScript==\n// @name non-strict i18n\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *:"
},
{
"path": "test/integration/interpolater/fixtures.ts",
"chars": 95,
"preview": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {}\n"
},
{
"path": "test/integration/interpolater/index.test.ts",
"chars": 1489,
"preview": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } from '../util';\nimport { Volume } f"
},
{
"path": "test/integration/load-headers/fixtures.ts",
"chars": 375,
"preview": "import { Memoize } from 'typescript-memoize';\n\nimport { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixture"
},
{
"path": "test/integration/load-headers/index.test.ts",
"chars": 5434,
"preview": "import path from 'node:path';\n\nimport { HeadersProps, UserscriptPlugin } from 'webpack-userscript';\nimport * as fs from "
},
{
"path": "test/integration/load-headers/load-headers.headers.txt",
"chars": 142,
"preview": "// ==UserScript==\n// @name load-headers\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*"
},
{
"path": "test/integration/multi-entry/entry1.headers.txt",
"chars": 136,
"preview": "// ==UserScript==\n// @name entry1\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// "
},
{
"path": "test/integration/multi-entry/entry2.headers.txt",
"chars": 136,
"preview": "// ==UserScript==\n// @name entry2\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// "
},
{
"path": "test/integration/multi-entry/entry3.headers.txt",
"chars": 136,
"preview": "// ==UserScript==\n// @name entry3\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*\n// "
},
{
"path": "test/integration/multi-entry/fixtures.ts",
"chars": 371,
"preview": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n @File(__dirname, '"
},
{
"path": "test/integration/multi-entry/index.test.ts",
"chars": 2529,
"preview": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../vo"
},
{
"path": "test/integration/package-json/bugs.headers.txt",
"chars": 123,
"preview": "// ==UserScript==\n// @name package-json-bugs\n// @supportURL https://bugs.example.com/\n// @match *://*/*\n// ==/UserScript"
},
{
"path": "test/integration/package-json/fixtures.ts",
"chars": 290,
"preview": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n @File(__dirname, '"
},
{
"path": "test/integration/package-json/index.test.ts",
"chars": 2216,
"preview": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../vo"
},
{
"path": "test/integration/package-json/root-option.headers.txt",
"chars": 241,
"preview": "// ==UserScript==\n// @name package-json-deep\n// @description description of package-json-deep\n// @version 0.0.1\n// @auth"
},
{
"path": "test/integration/proxy-script/base-url-proxy-script.headers.txt",
"chars": 208,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/proxy-script/base-url-proxy-script.proxy-headers.txt",
"chars": 191,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/proxy-script/fixtures.ts",
"chars": 559,
"preview": "import { File, GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n @File(__dirname, '"
},
{
"path": "test/integration/proxy-script/index.test.ts",
"chars": 2878,
"preview": "import { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile } from '../util';\nimport { Volume } from '../vo"
},
{
"path": "test/integration/proxy-script/proxy-script.headers.txt",
"chars": 246,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/proxy-script/proxy-script.proxy-headers.txt",
"chars": 228,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/resolve-base-urls/fixtures.ts",
"chars": 487,
"preview": "import { GlobalFixtures } from '../fixtures';\n\nexport class Fixtures extends GlobalFixtures {\n public static readonly d"
},
{
"path": "test/integration/resolve-base-urls/index.test.ts",
"chars": 3513,
"preview": "import { URL } from 'node:url';\n\nimport { UserscriptPlugin } from 'webpack-userscript';\n\nimport { compile, findTags } fr"
},
{
"path": "test/integration/ssri/algorithms-ssri-headers.txt",
"chars": 382,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/ssri/algorithms-ssri-lock.json.txt",
"chars": 213,
"preview": "{\"http://localhost:${PORT}/travis-webpack-userscript.svg\":\"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40=\",\"http://"
},
{
"path": "test/integration/ssri/filters-ssri-headers.txt",
"chars": 417,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/ssri/filters-ssri-lock.json.txt",
"chars": 146,
"preview": "{\"http://localhost:${PORT}/jquery-3.4.1.min.js\":\"sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0"
},
{
"path": "test/integration/ssri/fixtures.ts",
"chars": 2188,
"preview": "import path from 'node:path';\n\nimport { Memoize } from 'typescript-memoize';\n\nimport { File, GlobalFixtures } from '../f"
},
{
"path": "test/integration/ssri/index.test.ts",
"chars": 13912,
"preview": "import path from 'node:path';\n\nimport fetch from 'node-fetch';\nimport { UserscriptPlugin } from 'webpack-userscript';\n\ni"
},
{
"path": "test/integration/ssri/multi-algo-ssri-lock.json.txt",
"chars": 405,
"preview": "{\"http://localhost:${PORT}/travis-webpack-userscript.svg\":\"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40= sha512-/x"
},
{
"path": "test/integration/ssri/ssri-headers.txt",
"chars": 470,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/ssri/ssri-lock.json.txt",
"chars": 301,
"preview": "{\"http://localhost:${PORT}/travis-webpack-userscript.svg\":\"sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urn"
},
{
"path": "test/integration/ssri/static/.eslintignore",
"chars": 6,
"preview": "./**/*"
},
{
"path": "test/integration/ssri/unsupported-protocols.headers.txt",
"chars": 517,
"preview": "// ==UserScript==\n// @name userscript\n// @description this is a fantastic userscript\n// @version 0.0.0\n// @match *://*/*"
},
{
"path": "test/integration/util.ts",
"chars": 4966,
"preview": "import { mkdtemp, rm, writeFile } from 'node:fs/promises';\nimport { AddressInfo } from 'node:net';\nimport { tmpdir } fro"
},
{
"path": "test/integration/volume.ts",
"chars": 374,
"preview": "import { createFsFromVolume as memfsCreateFsFromVolume } from 'memfs';\nimport { Volume } from 'memfs/lib/volume';\nimport"
},
{
"path": "test/setup.ts",
"chars": 24,
"preview": "import 'jest-extended';\n"
},
{
"path": "tsconfig.build.json",
"chars": 76,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"exclude\": [\"test\", \"jest.config.ts\"]\n}\n"
},
{
"path": "tsconfig.json",
"chars": 906,
"preview": "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"target\": \"es6\",\n \"module\": \"commonjs\",\n \"moduleResolution\": \"nod"
},
{
"path": "typedoc.json",
"chars": 55,
"preview": "{\n \"entryPoints\": [\"lib/index.ts\"],\n \"out\": \"docs\"\n}\n"
}
]
About this extraction
This page contains the full source code of the momocow/webpack-userscript GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 96 files (144.7 KB), approximately 38.4k tokens, and a symbol index with 159 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.