├── .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-lock.json ├── package.json ├── test ├── integration │ ├── default-tags │ │ ├── fixtures.ts │ │ └── index.test.ts │ ├── fix-tags │ │ ├── fixtures.ts │ │ ├── headers.txt │ │ └── index.test.ts │ ├── fixtures.ts │ ├── fixtures │ │ ├── entry.js.txt │ │ ├── entry.min.js.txt │ │ ├── headers.txt │ │ └── package.json.txt │ ├── 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 │ │ │ ├── jquery-3.4.1.min.js │ │ │ └── travis-webpack-userscript.svg │ │ └── unsupported-protocols.headers.txt │ ├── util.ts │ └── volume.ts └── setup.ts ├── tsconfig.build.json ├── tsconfig.json └── typedoc.json /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | 'plugin:import/recommended', 13 | 'plugin:import/typescript', 14 | ], 15 | root: true, 16 | env: { 17 | node: true, 18 | jest: true, 19 | }, 20 | ignorePatterns: ['.eslintrc.js'], 21 | rules: { 22 | '@typescript-eslint/interface-name-prefix': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'error', 24 | '@typescript-eslint/explicit-module-boundary-types': 'error', 25 | '@typescript-eslint/explicit-member-accessibility': 'error', 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | 'max-len': [ 28 | 'error', 29 | { 30 | code: 80, 31 | ignoreComments: true, 32 | } 33 | ], 34 | 'simple-import-sort/imports': 'error', 35 | 'simple-import-sort/exports': 'error', 36 | 'import/first': 'error', 37 | 'import/newline-after-import': 'error', 38 | 'import/no-duplicates': 'error', 39 | 'padding-line-between-statements': [ 40 | 'error', 41 | { blankLine: 'always', prev: '*', next: 'return' }, 42 | { blankLine: 'always', prev: '*', next: 'throw' }, 43 | { blankLine: 'always', prev: '*', next: 'continue' }, 44 | ], 45 | }, 46 | settings: { 47 | 'import/resolver': { 48 | typescript: true, 49 | node: true, 50 | }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 4 | - https://www.buymeacoffee.com/momocow 5 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy docs to Pages 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow one concurrent deployment 16 | concurrency: 17 | group: 'pages' 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | # Single deploy job since we're just deploying 22 | deploy: 23 | environment: 24 | name: github-pages 25 | url: ${{ steps.deployment.outputs.page_url }} 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: 'lts/*' 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: Build 37 | run: npm run docs 38 | - name: Setup Pages 39 | uses: actions/configure-pages@v3 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v1 42 | with: 43 | # Upload entire repository 44 | path: 'docs/' 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v1 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - alpha 8 | 9 | jobs: 10 | release: 11 | name: Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 'lts/*' 22 | - name: Install dependencies 23 | run: npm ci 24 | - name: Clean 25 | run: npm run clean 26 | - name: Build 27 | run: npm run build 28 | - name: Release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 31 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | run: npm run release 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [lts/*, node] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci 22 | - run: npm run lint 23 | - run: npm test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | dist/ 63 | docs/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # ignore errors 5 | npx lint-staged || exit 0 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.ts": ["prettier --write", "eslint --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80 5 | } 6 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main", 4 | { 5 | "name": "alpha", 6 | "prerelease": true 7 | } 8 | ], 9 | "plugins": [ 10 | "semantic-release-gitmoji", 11 | "@semantic-release/github", 12 | "@semantic-release/npm", 13 | [ 14 | "@semantic-release/git", 15 | { 16 | "message": ":bookmark: v${nextRelease.version} [skip ci]\n\nhttps://github.com/momocow/webpack-userscript/releases/tag/${nextRelease.gitTag}" 17 | } 18 | ] 19 | ] 20 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MomoCow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webpack-userscript 2 | 3 | [![Test Status](https://github.com/momocow/webpack-userscript/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/momocow/webpack-userscript/actions/workflows/test.yaml) 4 | [![Release Status](https://github.com/momocow/webpack-userscript/actions/workflows/release.yaml/badge.svg?branch=main)](https://github.com/momocow/webpack-userscript/actions/workflows/release.yaml) 5 | [![Coding Style](https://img.shields.io/badge/coding%20style-recommended-orange.svg?style=flat)](https://gitmoji.carloscuesta.me/) 6 | [![npm](https://img.shields.io/npm/v/webpack-userscript.svg)](https://www.npmjs.com/package/webpack-userscript/v/latest) 7 | [![Gitmoji](https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=flat-square)](https://gitmoji.carloscuesta.me/) 8 | 9 | A Webpack plugin for userscript projects 🙈 10 | 11 | - [webpack-userscript](#webpack-userscript) 12 | - [Overview](#overview) 13 | - [Installation](#installation) 14 | - [Usage](#usage) 15 | - [Options](#options) 16 | - [Concepts](#concepts) 17 | - [What does it actually do?](#what-does-it-actually-do) 18 | - [Prepend headers to userscripts](#prepend-headers-to-userscripts) 19 | - [Generate metadata files](#generate-metadata-files) 20 | - [Generate proxyscript files](#generate-proxyscript-files) 21 | - [Headers pipeline](#headers-pipeline) 22 | - [Load headers](#load-headers) 23 | - [Rename ambiguous tags](#rename-ambiguous-tags) 24 | - [Resolve base URLs](#resolve-base-urls) 25 | - [Process SSRIs](#process-ssris) 26 | - [Provide default values for tags](#provide-default-values-for-tags) 27 | - [Generate proxyscripts](#generate-proxyscripts) 28 | - [Interpolate templates inside values](#interpolate-templates-inside-values) 29 | - [Validate headers](#validate-headers) 30 | - [Render headers](#render-headers) 31 | - [Guides](#guides) 32 | - [Hot Development](#hot-development) 33 | - [Integration with Webpack Dev Server and TamperMonkey](#integration-with-webpack-dev-server-and-tampermonkey) 34 | - [I18n headers](#i18n-headers) 35 | - [Furthermore](#furthermore) 36 | 37 | ## Overview 38 | 39 | ### Installation 40 | 41 | ```bash 42 | npm i webpack-userscript -D 43 | ``` 44 | 45 | ### Usage 46 | 47 | Import and configure the plugin in the `webpack.config.js` as the following example. 48 | 49 | ```js 50 | const { UserscriptPlugin } = require('webpack-userscript'); 51 | 52 | module.exports = { 53 | plugins: [new UserscriptPlugin(/* optionally provide more options here */)], 54 | }; 55 | ``` 56 | 57 | ### Options 58 | 59 | See [UserscriptOptions](https://cow.moe/webpack-userscript/types/UserscriptOptions.html) for all configurations. 60 | 61 | ## Concepts 62 | 63 | ### What does it actually do? 64 | 65 | #### Prepend headers to userscripts 66 | 67 | 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`. 68 | 69 | 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. 70 | 71 | Here are some references to headers definitions of userscript engines: 72 | 73 | - [TamperMonkey: userscript headers](https://www.tampermonkey.net/documentation.php#meta) 74 | - [GreaseMonkey: metadata block](https://wiki.greasespot.net/Metadata_Block) 75 | - [GreasyFork: meta keys](https://greasyfork.org/en/help/meta-keys) 76 | - [ViolentMonkey: metadata block](https://violentmonkey.github.io/api/metadata-block/) 77 | 78 | #### Generate metadata files 79 | 80 | 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`. 81 | 82 | #### Generate proxyscript files 83 | 84 | 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. 85 | 86 | > See more details in [issue #63](https://github.com/momocow/webpack-userscript/issues/63) 87 | 88 | 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). 89 | 90 | ### Headers pipeline 91 | 92 | #### Load headers 93 | 94 | Headers can be provided directly as an object, a string referencing to a file, or a function returning an object of headers. 95 | 96 | The plugin will also try to load initial headers from the following fields, `name`, `description`, `version`, `author`, `homepage` and `bugs` in `package.json`. 97 | 98 | Headers files are added as a file dependency; therefore, changes to headers files are watched by Webpack during developing in the watch mode. 99 | 100 | #### Rename ambiguous tags 101 | 102 | The main purpose is to fix misspelling or wrong letter case. 103 | 104 | - `updateUrl` => `updateURL` 105 | - `iconUrl` => `iconURL` 106 | - `icon64Url` => `icon64URL` 107 | - `installUrl` => `installURL` 108 | - `supportUrl` => `supportURL` 109 | - `downloadUrl` => `downloadURL` 110 | - `homepageUrl` => `homepageURL` 111 | 112 | #### Resolve base URLs 113 | 114 | Base URLs are resolved for `downloadURL` and `updateURL`. 115 | 116 | If `updateBaseURL` is not provided, `downloadBaseURL` will be used; if metajs is disabled, `updateURL` will point to the file of userjs. 117 | 118 | #### Process SSRIs 119 | 120 | [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. 121 | 122 | 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`. 123 | 124 | 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. 125 | 126 | If one cannot ensure 3rd-party assets to be trustable, he can modify the lock file himself with trustable integrities of assets. 127 | 128 | > Note that the lock file should be commited into the version control system just like `package-lock.json`. 129 | 130 | #### Provide default values for tags 131 | 132 | If there is no any `@include` or `@match` tag provided, a wildcard `@match`, `*://*/*`, is used. 133 | 134 | #### Generate proxyscripts 135 | 136 | 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`. 137 | 138 | #### Interpolate templates inside values 139 | 140 | 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. 141 | 142 | Possible template variables are as follows. 143 | 144 | - `[name]`: chunk name 145 | - `[buildNo]`: build number starting from 1 at beginning of watch mode 146 | - `[buildTime]`: the timestamp in millisecond when the compilation starts 147 | - `[file]`: full path of the file 148 | - = `[dirname]` + `[basename]` + `[extname]` + `[query]` 149 | - `[filename]`: file path 150 | - = `[basename]` + `[extname]` 151 | - `[dirname]`: directory path 152 | - `[basename]`: file base name 153 | - `[extname]`: file extension starting with `.` 154 | - `[query]`: query string starting with `?` 155 | 156 | > Note that `[buildNo]` starts from 0 and will increase during developing in the watch mode. 157 | > Once exiting from the watch mode, it will be reset. 158 | 159 | For example, one can embed the build time into `@version` tag via the following configuration. 160 | 161 | ```js 162 | new UserscriptPlugin({ 163 | headers: { 164 | version: '0.0.1-beta.[buildTime]' 165 | }, 166 | }) 167 | ``` 168 | 169 | #### Validate headers 170 | 171 | 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). 172 | 173 | The configuration defaults to strict mode, which means extra tags are not allowed and type checking to headers values are performed. 174 | 175 | One can provide `headersClass` option to override the default `Headers` class; but it is suggested to inherit from the original one. 176 | 177 | > Note that the `headersClass` is used for both main headers and i18n headers. 178 | > Check the [default implementation](lib/features/validate-headers/headers.ts) before customizing your own. 179 | 180 | #### Render headers 181 | 182 | Headers in all locales are merged and rendered. 183 | 184 | There are 2 useful options here, `pretty` and `tagOrder`. 185 | 186 | The `pretty` option is a boolean deciding whether to render the headers as a table or not. 187 | 188 | 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. 189 | 190 | ## Guides 191 | 192 | ### Hot Development 193 | 194 | The following example can be used in development mode with the help of [`webpack-dev-server`](https://github.com/webpack/webpack-dev-server). 195 | 196 | `webpack-dev-server` will build the userscript in **watch** mode. Each time the project is built, the `buildNo` variable will increase by 1. 197 | 198 | > **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. 199 | 200 | 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. 201 | 202 | After the first time starting the `webpack-dev-server`, you can install the script via `http://localhost:8080/.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. 203 | 204 | - `webpack.config.dev.js` 205 | 206 | ```js 207 | const path = require('path'); 208 | const { UserscriptPlugin } = require('webpack-userscript'); 209 | const dev = process.env.NODE_ENV === 'development'; 210 | 211 | module.exports = { 212 | mode: dev ? 'development' : 'production', 213 | entry: path.resolve(__dirname, 'src', 'index.js'), 214 | output: { 215 | path: path.resolve(__dirname, 'dist'), 216 | filename: '.user.js', 217 | }, 218 | devServer: { 219 | contentBase: path.join(__dirname, 'dist'), 220 | }, 221 | plugins: [ 222 | new UserscriptPlugin({ 223 | headers(original) { 224 | if (dev) { 225 | return { 226 | ...original, 227 | version: `${original.version}-build.[buildNo]`, 228 | } 229 | } 230 | 231 | return original; 232 | }, 233 | }), 234 | ], 235 | }; 236 | ``` 237 | 238 | ### Integration with Webpack Dev Server and TamperMonkey 239 | 240 | If you feel tired with firing the update button on TamperMonkey GUI, maybe you can have a try at proxy script. 241 | 242 | 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)) 243 | 244 | 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. 245 | 246 | > 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. 247 | > 248 | > I have no idea why TamperMonkey is desinged this way. But..., it's up to you! 249 | 250 | To enable the proxy script, provide a `proxyScript` configuration to the plugin constructor. 251 | 252 | `baseURL` should be the base URL of the dev server, and the `filename` is for the proxy script. 253 | 254 | > Note: `filename` will be interpolated. 255 | 256 | After starting the dev server, you can find your proxy script under `/`. 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. 257 | 258 | See [Issue#63](https://github.com/momocow/webpack-userscript/issues/63) for more information. 259 | 260 | ```js 261 | new WebpackUserscript({ 262 | // <...your other configs...>, 263 | proxyScript: { 264 | baseURL: 'http://127.0.0.1:12345', 265 | filename: '[basename].proxy.user.js', 266 | }, 267 | }); 268 | ``` 269 | 270 | 271 | 272 | 273 | ### I18n headers 274 | 275 | 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. 276 | 277 | ```js 278 | new UserscriptPlugin({ 279 | headers: { 280 | name: 'this is the main script name' 281 | }, 282 | i18n: { 283 | // headers object 284 | 'en-US': { 285 | name: 'this is a localized name' 286 | }, 287 | }, 288 | }) 289 | ``` 290 | 291 | 292 | ```js 293 | new UserscriptPlugin({ 294 | headers: { 295 | name: 'this is the main script name' 296 | }, 297 | i18n: { 298 | // headers file 299 | 'en-US': '/dir/to/headers.json' // whose content is `{"name": "this is a localized name"}` 300 | }, 301 | }) 302 | ``` 303 | 304 | ```js 305 | new UserscriptPlugin({ 306 | headers: { 307 | name: 'this is the main script name' 308 | }, 309 | i18n: { 310 | // headers provider 311 | 'en-US': (headers) => ({ 312 | ...headers, 313 | name: 'this is a localized name' 314 | }), 315 | }, 316 | }) 317 | ``` 318 | 319 | 320 | With the above configurations will generate the following headers, 321 | 322 | ```js 323 | // ==UserScript== 324 | // @name this is the main script name 325 | // @name:en-US this is a localized name 326 | // @version 0.0.0 327 | // @match *://*/* 328 | // ==/UserScript== 329 | ``` 330 | 331 | ## Furthermore 332 | - [Get started with Webpack](https://webpack.js.org/guides/getting-started/) 333 | - [How to write userscript in TypeScript?](https://github.com/momocow/webpack-userscript/issues/95) 334 | - [Solution to userscript not refreshing on every page load](https://github.com/momocow/webpack-userscript/issues/63) -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { pathsToModuleNameMapper } from 'ts-jest'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const { compilerOptions } = require('./tsconfig.json'); 5 | 6 | const EXCLUDE_PATHS = new Set(['class-transformer/cjs/storage']); 7 | 8 | module.exports = { 9 | clearMocks: true, 10 | testMatch: [ 11 | '**/__tests__/**/*.+(ts|tsx|js)', 12 | '**/?(*.)+(spec|test).+(ts|tsx|js)', 13 | ], 14 | transform: { 15 | '^.+\\.(ts|tsx)$': [ 16 | 'ts-jest', 17 | { 18 | diagnostics: { warnOnly: process.env.NODE_ENV === 'development' }, 19 | }, 20 | ], 21 | }, 22 | modulePaths: [compilerOptions.baseUrl], 23 | moduleNameMapper: pathsToModuleNameMapper( 24 | Object.fromEntries( 25 | Object.entries(compilerOptions.paths).filter( 26 | (e): e is [string, string[]] => !EXCLUDE_PATHS.has(e[0]), 27 | ), 28 | ), 29 | ), 30 | setupFiles: ['./test/setup.ts'], 31 | setupFilesAfterEnv: ['jest-extended/all'], 32 | }; 33 | -------------------------------------------------------------------------------- /lib/const.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LOCALE_KEY = ''; 2 | -------------------------------------------------------------------------------- /lib/features/default-tags.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LOCALE_KEY } from '..//const'; 2 | import { UserscriptPluginInstance } from '../types'; 3 | import { Feature } from './feature'; 4 | 5 | export class SetDefaultTags extends Feature { 6 | public readonly name = 'SetDefaultTags'; 7 | 8 | public apply({ hooks }: UserscriptPluginInstance): void { 9 | hooks.headers.tap(this.constructor.name, (headers, { locale }) => { 10 | if ( 11 | locale === DEFAULT_LOCALE_KEY && 12 | headers.include === undefined && 13 | headers.match === undefined 14 | ) { 15 | return { 16 | ...headers, 17 | match: '*://*/*', 18 | }; 19 | } 20 | 21 | return headers; 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/features/feature.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPluginInstance } from '../types'; 2 | 3 | export abstract class Feature { 4 | public constructor(public readonly options: Options) {} 5 | 6 | public abstract readonly name: string; 7 | public abstract apply(plugin: UserscriptPluginInstance): void; 8 | } 9 | -------------------------------------------------------------------------------- /lib/features/fix-tags.ts: -------------------------------------------------------------------------------- 1 | import { StrictHeadersProps, UserscriptPluginInstance } from '../types'; 2 | import { Feature } from './feature'; 3 | 4 | export class FixTags extends Feature { 5 | public readonly name = 'FixTags'; 6 | 7 | public readonly fixableTagNames = new Map([ 8 | ['updateUrl', 'updateURL'], 9 | ['iconUrl', 'iconURL'], 10 | ['icon64Url', 'icon64URL'], 11 | ['installUrl', 'installURL'], 12 | ['supportUrl', 'supportURL'], 13 | ['downloadUrl', 'downloadURL'], 14 | ['homepageUrl', 'homepageURL'], 15 | ]); 16 | 17 | public apply({ hooks }: UserscriptPluginInstance): void { 18 | hooks.headers.tap(this.name, (headers) => { 19 | for (const [source, target] of this.fixableTagNames) { 20 | if (headers[source] !== undefined) { 21 | if (headers[target] !== undefined) { 22 | throw new Error(`ambiguous tags: ("${source}", "${target}")`); 23 | } 24 | 25 | headers = { 26 | ...headers, 27 | [source]: undefined, 28 | [target]: headers[source], 29 | }; 30 | } 31 | } 32 | 33 | return headers; 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/features/index.ts: -------------------------------------------------------------------------------- 1 | export * from './default-tags'; 2 | export * from './feature'; 3 | export * from './fix-tags'; 4 | export * from './interpolater'; 5 | export * from './load-headers'; 6 | export * from './proxy-script'; 7 | export * from './render-headers'; 8 | export * from './resolve-base-urls'; 9 | export * from './ssri'; 10 | export * from './validate-headers'; 11 | -------------------------------------------------------------------------------- /lib/features/interpolater.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HeadersProps, 3 | UserscriptPluginInstance, 4 | ValueType, 5 | WaterfallContext, 6 | } from '../types'; 7 | import { Feature } from './feature'; 8 | 9 | export class Interpolater extends Feature { 10 | public readonly name = 'Interpolater'; 11 | 12 | public apply({ hooks }: UserscriptPluginInstance): void { 13 | hooks.headers.tap(this.name, (headers, ctx) => 14 | this.interpolate(headers, this.getVariables(ctx)), 15 | ); 16 | 17 | hooks.proxyHeaders.tap(this.name, (headers, ctx) => 18 | this.interpolate(headers, this.getVariables(ctx)), 19 | ); 20 | 21 | hooks.proxyScriptFile.tap(this.name, (filepath, ctx) => 22 | this.interpolate(filepath, this.getVariables(ctx)), 23 | ); 24 | } 25 | 26 | private getVariables({ 27 | fileInfo: { 28 | chunk, 29 | originalFile, 30 | filename, 31 | basename, 32 | query, 33 | dirname, 34 | extname, 35 | }, 36 | buildNo, 37 | buildTime, 38 | }: WaterfallContext): Record { 39 | return { 40 | name: chunk.name ?? '', 41 | file: originalFile, 42 | filename, 43 | basename, 44 | query, 45 | dirname, 46 | extname, 47 | buildNo: buildNo.toString(), 48 | buildTime: buildTime.toISOString(), 49 | }; 50 | } 51 | 52 | private interpolate( 53 | data: HeadersProps, 54 | info: Record, 55 | ): HeadersProps; 56 | private interpolate( 57 | data: T, 58 | info: Record, 59 | ): T; 60 | private interpolate( 61 | data: HeadersProps | ValueType, 62 | info: Record, 63 | ): HeadersProps | ValueType { 64 | if (typeof data === 'string') { 65 | return Object.entries(info).reduce((value, [dataKey, dataVal]) => { 66 | return value.replace(new RegExp(`\\[${dataKey}\\]`, 'g'), `${dataVal}`); 67 | }, data); 68 | } 69 | 70 | if (Array.isArray(data)) { 71 | return data.map((item) => this.interpolate(item, info)); 72 | } 73 | 74 | if (typeof data === 'object' && data !== null) { 75 | return Object.fromEntries( 76 | Object.entries(data).map(([k, v]) => [ 77 | this.interpolate(k, info), 78 | this.interpolate(v, info), 79 | ]), 80 | ) as HeadersProps; 81 | } 82 | 83 | return data; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/features/load-headers/impl.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LOCALE_KEY } from '../../const'; 2 | import { 3 | HeadersProps, 4 | UserscriptPluginInstance, 5 | WaterfallContext, 6 | } from '../../types'; 7 | import { Feature } from '../feature'; 8 | import { 9 | FileLoader, 10 | HeadersFile, 11 | HeadersProvider, 12 | ObjectLoader, 13 | PackageLoader, 14 | ProviderLoader, 15 | } from './loaders'; 16 | 17 | export type HeadersOption = HeadersProps | HeadersFile | HeadersProvider; 18 | 19 | export interface LoadHeadersOptions { 20 | root?: string; 21 | headers?: HeadersOption; 22 | i18n?: Record; 23 | } 24 | 25 | export class LoadHeaders extends Feature { 26 | public readonly name = 'LoadHeaders'; 27 | 28 | private packageLoader!: PackageLoader; 29 | private objectLoaders: Map = new Map(); 30 | private fileLoaders: Map = new Map(); 31 | private providerLoaders: Map = new Map(); 32 | 33 | public apply({ hooks }: UserscriptPluginInstance): void { 34 | const { headers: headersOption, i18n = {}, root } = this.options; 35 | 36 | this.packageLoader = new PackageLoader(root); 37 | 38 | if (headersOption) { 39 | this.addLoader(DEFAULT_LOCALE_KEY, headersOption); 40 | } 41 | 42 | for (const [locale, i18nHeadersOption] of Object.entries(i18n)) { 43 | this.addLoader(locale, i18nHeadersOption); 44 | } 45 | 46 | hooks.init.tapPromise(this.name, async (compiler) => { 47 | await this.packageLoader.load(compiler); 48 | }); 49 | 50 | if (this.fileLoaders.size > 0) { 51 | hooks.preprocess.tapPromise(this.name, async (compilation) => { 52 | await Promise.all( 53 | Array.from(this.fileLoaders.values()).map((fileLoader) => 54 | fileLoader.load(compilation), 55 | ), 56 | ); 57 | }); 58 | } 59 | 60 | hooks.headers.tapPromise(this.name, async (_, ctx) => 61 | this.provideHeaders(ctx), 62 | ); 63 | } 64 | 65 | private addLoader(locale: string, headersOption: HeadersOption): void { 66 | if (typeof headersOption === 'object') { 67 | this.objectLoaders.set(locale, new ObjectLoader(headersOption)); 68 | 69 | return; 70 | } 71 | 72 | if (typeof headersOption === 'string') { 73 | this.fileLoaders.set( 74 | locale, 75 | new FileLoader(headersOption, this.options.root), 76 | ); 77 | 78 | return; 79 | } 80 | 81 | this.providerLoaders.set(locale, new ProviderLoader(headersOption)); 82 | } 83 | 84 | private async provideHeaders(ctx: WaterfallContext): Promise { 85 | const { locale } = ctx; 86 | const headersBase = {}; 87 | 88 | if (locale === DEFAULT_LOCALE_KEY) { 89 | Object.assign(headersBase, this.packageLoader.headers); 90 | } 91 | Object.assign(headersBase, this.objectLoaders.get(locale)?.headers); 92 | Object.assign(headersBase, this.fileLoaders.get(locale)?.headers); 93 | 94 | return ( 95 | this.providerLoaders.get(locale)?.load(headersBase, ctx) ?? headersBase 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/features/load-headers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './impl'; 2 | export * from './loaders'; 3 | -------------------------------------------------------------------------------- /lib/features/load-headers/loaders.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { promisify } from 'node:util'; 3 | 4 | import { Compilation, Compiler } from 'webpack'; 5 | 6 | import { findPackage, FsReadFile, FsStat, readJSON } from '../../fs'; 7 | import { HeadersProps, WaterfallContext } from '../../types'; 8 | 9 | export class ObjectLoader { 10 | public constructor(public headers: HeadersProps) {} 11 | 12 | public load(): HeadersProps { 13 | return this.headers; 14 | } 15 | } 16 | 17 | export type HeadersFile = string; 18 | 19 | export class FileLoader { 20 | public headers?: HeadersProps; 21 | 22 | private headersFileTimestamp = 0; 23 | 24 | public constructor(private file: HeadersFile, private root?: string) {} 25 | 26 | public async load(compilation: Compilation): Promise { 27 | const getFileTimestampAsync = promisify( 28 | compilation.fileSystemInfo.getFileTimestamp.bind( 29 | compilation.fileSystemInfo, 30 | ), 31 | ); 32 | 33 | const resolvedHeadersFile = path.resolve( 34 | this.root ?? compilation.compiler.context, 35 | this.file, 36 | ); 37 | 38 | const ts = await getFileTimestampAsync(resolvedHeadersFile); 39 | 40 | if ( 41 | this.headers && 42 | ts && 43 | typeof ts === 'object' && 44 | typeof ts.timestamp === 'number' && 45 | this.headersFileTimestamp >= ts.timestamp 46 | ) { 47 | // file no change 48 | return this.headers; 49 | } 50 | 51 | if (ts && typeof ts === 'object') { 52 | this.headersFileTimestamp = ts.timestamp ?? this.headersFileTimestamp; 53 | } 54 | 55 | compilation.fileDependencies.add(resolvedHeadersFile); 56 | 57 | return (this.headers = await readJSON( 58 | resolvedHeadersFile, 59 | compilation.inputFileSystem as FsReadFile, 60 | )); 61 | } 62 | } 63 | 64 | export type HeadersProvider = ( 65 | headers: HeadersProps, 66 | ctx: WaterfallContext, 67 | ) => HeadersProps | Promise; 68 | 69 | export class ProviderLoader { 70 | public constructor(private provider: HeadersProvider) {} 71 | 72 | public async load( 73 | headers: HeadersProps, 74 | ctx: WaterfallContext, 75 | ): Promise { 76 | return this.provider(headers, ctx); 77 | } 78 | } 79 | 80 | interface PackageInfo { 81 | name?: string; 82 | version?: string; 83 | description?: string; 84 | author?: string; 85 | homepage?: string; 86 | bugs?: string | { url?: string }; 87 | } 88 | 89 | export class PackageLoader { 90 | public headers?: HeadersProps; 91 | 92 | public constructor(private root?: string) {} 93 | 94 | public async load(compiler: Compiler): Promise { 95 | if (!this.headers) { 96 | try { 97 | const cwd = await findPackage( 98 | this.root ?? compiler.context, 99 | compiler.inputFileSystem as FsStat, 100 | ); 101 | const packageJson = await readJSON( 102 | path.join(cwd, 'package.json'), 103 | compiler.inputFileSystem as FsReadFile, 104 | ); 105 | 106 | this.headers = { 107 | name: packageJson.name, 108 | version: packageJson.version, 109 | description: packageJson.description, 110 | author: packageJson.author, 111 | homepage: packageJson.homepage, 112 | supportURL: 113 | typeof packageJson.bugs === 'string' 114 | ? packageJson.bugs 115 | : typeof packageJson.bugs === 'object' && 116 | typeof packageJson.bugs.url === 'string' 117 | ? packageJson.bugs.url 118 | : undefined, 119 | }; 120 | } catch (e) { 121 | this.headers = {}; 122 | } 123 | } 124 | 125 | return this.headers; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /lib/features/proxy-script.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | 3 | import { UserscriptPluginInstance } from '../types'; 4 | import { Feature } from './feature'; 5 | 6 | export interface ProxyScriptFeatureOptions { 7 | filename?: string; 8 | baseURL?: string; 9 | } 10 | 11 | export interface ProxyScriptOptions { 12 | proxyScript?: ProxyScriptFeatureOptions; 13 | } 14 | 15 | export class ProcessProxyScript extends Feature { 16 | public readonly name = 'ProcessProxyScript'; 17 | 18 | public apply({ hooks }: UserscriptPluginInstance): void { 19 | const { proxyScript } = this.options; 20 | 21 | if (proxyScript) { 22 | hooks.proxyHeaders.tap( 23 | this.name, 24 | (headers, { fileInfo: { userjsFile } }) => { 25 | const devBaseUrl = !proxyScript.baseURL 26 | ? 'http://localhost:8080/' 27 | : proxyScript.baseURL; 28 | 29 | const requireTags = Array.isArray(headers.require) 30 | ? headers.require 31 | : typeof headers.require === 'string' 32 | ? [headers.require] 33 | : []; 34 | 35 | headers = { 36 | ...headers, 37 | require: [ 38 | ...requireTags, 39 | new URL(userjsFile, devBaseUrl).toString(), 40 | ], 41 | downloadURL: undefined, 42 | updateURL: undefined, 43 | installURL: undefined, 44 | }; 45 | 46 | return headers; 47 | }, 48 | ); 49 | 50 | hooks.proxyScriptFile.tap(this.name, () => { 51 | if (!proxyScript.filename) { 52 | return '[basename].proxy.user.js'; 53 | } else { 54 | return proxyScript.filename; 55 | } 56 | }); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/features/render-headers.ts: -------------------------------------------------------------------------------- 1 | import { getBorderCharacters, table } from 'table'; 2 | 3 | import { DEFAULT_LOCALE_KEY } from '../const'; 4 | import { 5 | HeadersProps, 6 | TagType, 7 | UserscriptPluginInstance, 8 | ValueType, 9 | } from '../types'; 10 | import { Feature } from './feature'; 11 | 12 | export interface RenderHeadersOptions { 13 | prefix?: string; 14 | suffix?: string; 15 | pretty?: boolean; 16 | tagOrder?: TagType[]; 17 | proxyScript?: unknown; 18 | } 19 | 20 | export class RenderHeaders extends Feature { 21 | public readonly name = 'RenderHeaders'; 22 | 23 | public apply({ hooks }: UserscriptPluginInstance): void { 24 | hooks.renderHeaders.tap(this.name, (headersMap) => 25 | this.render(this.mergeHeadersMap(headersMap), this.options), 26 | ); 27 | 28 | if (this.options.proxyScript) { 29 | hooks.renderProxyHeaders.tap(this.name, (headers) => 30 | this.render(headers, this.options), 31 | ); 32 | } 33 | } 34 | 35 | private mergeHeadersMap(headersMap: Map): HeadersProps { 36 | return Array.from(headersMap) 37 | .map( 38 | ([locale, headers]) => 39 | Object.fromEntries( 40 | Object.entries(headers).map(([tag, value]) => [ 41 | locale === DEFAULT_LOCALE_KEY ? tag : `${tag}:${locale}`, 42 | value, 43 | ]), 44 | ) as HeadersProps, 45 | ) 46 | .reduce((h1, h2) => ({ ...h1, ...h2 })); 47 | } 48 | 49 | private render( 50 | headers: HeadersProps, 51 | { 52 | prefix = '// ==UserScript==\n', 53 | suffix = '// ==/UserScript==\n', 54 | pretty = false, 55 | tagOrder = [ 56 | 'name', 57 | 'description', 58 | 'version', 59 | 'author', 60 | 'homepage', 61 | 'supportURL', 62 | 'include', 63 | 'exclude', 64 | 'match', 65 | ], 66 | }: RenderHeadersOptions = {}, 67 | ): string { 68 | const orderRevMap = new Map(tagOrder.map((tag, index) => [tag, index])); 69 | const rows = Object.entries(headers) 70 | .sort( 71 | ([tag1], [tag2]) => 72 | (orderRevMap.get(this.getTagName(tag1)) ?? orderRevMap.size) - 73 | (orderRevMap.get(this.getTagName(tag2)) ?? orderRevMap.size) || 74 | (tag1 > tag2 ? 1 : tag1 < tag2 ? -1 : 0), 75 | ) 76 | .flatMap(([tag, value]) => this.renderTag(tag, value)); 77 | const body = pretty 78 | ? table(rows, { 79 | border: getBorderCharacters('void'), 80 | columnDefault: { 81 | paddingLeft: 0, 82 | paddingRight: 1, 83 | }, 84 | drawHorizontalLine: () => false, 85 | }) 86 | .split('\n') 87 | .map((line) => line.trim()) 88 | .join('\n') 89 | : rows.map((cols) => cols.join(' ')).join('\n') + '\n'; 90 | 91 | return prefix + body + suffix; 92 | } 93 | 94 | protected renderTag(tag: TagType, value: ValueType): string[][] { 95 | if (Array.isArray(value)) { 96 | return value.map((v) => [`// @${tag}`, v ?? '']); 97 | } 98 | if (typeof value === 'object') { 99 | return Object.entries(value).map(([k, v]) => [ 100 | `// @${tag}`, 101 | `${k} ${v ?? ''}`, 102 | ]); 103 | } 104 | if (typeof value === 'string') { 105 | return [[`// @${tag}`, value]]; 106 | } 107 | if (value === true) { 108 | return [[`// @${tag}`, '']]; 109 | } 110 | 111 | return []; 112 | } 113 | 114 | private getTagName(tag: string): string { 115 | return tag.replace(/:.+$/, ''); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /lib/features/resolve-base-urls.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | 3 | import { UserscriptPluginInstance } from '../types'; 4 | import { Feature } from './feature'; 5 | 6 | export interface ResolveBaseURLsOptions { 7 | downloadBaseURL?: string | URL; 8 | updateBaseURL?: string | URL; 9 | metajs?: boolean; 10 | } 11 | 12 | export class ResolveBaseURLs extends Feature { 13 | public readonly name = 'ResolveBaseURLs'; 14 | 15 | public apply({ hooks }: UserscriptPluginInstance): void { 16 | const { metajs, downloadBaseURL, updateBaseURL } = this.options; 17 | 18 | if (downloadBaseURL === undefined) { 19 | return; 20 | } 21 | 22 | hooks.headers.tap( 23 | this.name, 24 | (headers, { fileInfo: { userjsFile, metajsFile } }) => { 25 | if (headers.downloadURL === undefined) { 26 | headers = { 27 | ...headers, 28 | downloadURL: new URL(userjsFile, downloadBaseURL).toString(), 29 | }; 30 | } 31 | 32 | if (headers.updateURL === undefined) { 33 | headers = { 34 | ...headers, 35 | updateURL: new URL( 36 | metajs ? metajsFile : userjsFile, 37 | updateBaseURL ?? downloadBaseURL, 38 | ).toString(), 39 | }; 40 | } 41 | 42 | return headers; 43 | }, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/features/ssri.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import fetch from 'node-fetch'; 4 | import pLimit from 'p-limit'; 5 | import { 6 | fromStream, 7 | IntegrityMap, 8 | parse as parseSSRI, 9 | stringify as stringifySSRI, 10 | } from 'ssri'; 11 | import { Readable } from 'stream'; 12 | import { URL } from 'url'; 13 | 14 | import { 15 | FsMkdir, 16 | FsReadFile, 17 | FsWriteFile, 18 | mkdirp, 19 | readJSON, 20 | writeJSON, 21 | } from '../fs'; 22 | import { HeadersProps, UserscriptPluginInstance } from '../types'; 23 | import { Feature } from './feature'; 24 | 25 | export type RawSSRILock = Record; 26 | export type SSRILock = Record; 27 | 28 | export type SSRIAlgorithm = 'sha256' | 'sha384' | 'sha512'; 29 | 30 | export type SSRITag = 'require' | 'resource'; 31 | 32 | export type URLFilter = (tag: SSRITag, value: URL) => boolean; 33 | 34 | export interface SSRIFeatureOptions { 35 | include?: URLFilter; 36 | exclude?: URLFilter; 37 | algorithms?: SSRIAlgorithm[]; 38 | strict?: boolean; 39 | lock?: boolean | string; 40 | concurrency?: number; 41 | } 42 | 43 | export interface SSRIOptions { 44 | root?: string; 45 | ssri?: SSRIFeatureOptions; 46 | } 47 | 48 | export class ProcessSSRI extends Feature { 49 | public readonly name = 'ProcessSSRI'; 50 | 51 | public readonly allowedProtocols = new Set(['http:', 'https:']); 52 | 53 | private ssriLockDirty = false; 54 | private ssriLock: SSRILock = {}; 55 | 56 | private readonly limit = pLimit( 57 | (typeof this.options.ssri === 'object' 58 | ? this.options.ssri.concurrency 59 | : undefined) ?? 6, 60 | ); 61 | 62 | public apply({ hooks }: UserscriptPluginInstance): void { 63 | const { ssri, root } = this.options; 64 | 65 | if (!ssri) { 66 | return; 67 | } 68 | 69 | // read lock 70 | hooks.init.tapPromise(this.name, async (compiler) => { 71 | const lockfile = this.getSSRILockFile(ssri); 72 | 73 | if (!lockfile) { 74 | return; 75 | } 76 | 77 | try { 78 | this.ssriLock = this.parseSSRILock( 79 | await readJSON( 80 | path.resolve(root ?? compiler.context, lockfile), 81 | compiler.inputFileSystem as FsReadFile, 82 | ), 83 | ); 84 | } catch {} 85 | this.ssriLockDirty = false; 86 | }); 87 | 88 | // write lock 89 | hooks.close.tapPromise(this.name, async (compiler) => { 90 | const lock = this.getSSRILockFile(ssri); 91 | 92 | if (!lock) { 93 | return; 94 | } 95 | 96 | const lockfile = path.resolve(root ?? compiler.context, lock); 97 | 98 | if (this.ssriLockDirty) { 99 | const { intermediateFileSystem } = compiler; 100 | 101 | const dir = path.dirname(lockfile); 102 | const isNotRoot = path.dirname(dir) !== dir; 103 | 104 | if (isNotRoot) { 105 | await mkdirp(dir, intermediateFileSystem as FsMkdir); 106 | } 107 | 108 | await writeJSON( 109 | lockfile, 110 | this.toRawSSRILock(this.ssriLock), 111 | intermediateFileSystem as FsWriteFile, 112 | ); 113 | 114 | this.ssriLockDirty = false; 115 | } 116 | }); 117 | 118 | hooks.headers.tapPromise(this.name, async (headers) => { 119 | const targetURLs = this.getTargetURLs(headers, ssri).reduce( 120 | (map, url) => map.set(url, this.normalizeURL(url)), 121 | new Map(), 122 | ); 123 | 124 | if (targetURLs.size === 0) { 125 | return headers; 126 | } 127 | 128 | // merge integrities from ssri-lock 129 | // and those provided within headers option (in respective tags) 130 | for (const [url, normalizedURL] of targetURLs) { 131 | const integrity = parseSSRI(this.parseSSRILike(url), { 132 | strict: true, 133 | }) as IntegrityMap | null; 134 | 135 | if (integrity) { 136 | if (this.ssriLock[normalizedURL]) { 137 | integrity.merge(this.ssriLock[normalizedURL], { 138 | strict: true, 139 | }); 140 | } 141 | 142 | this.ssriLockDirty = true; 143 | this.ssriLock[normalizedURL] = integrity; 144 | } 145 | } 146 | 147 | // compute and merge missing hashes based on specified algorithms option 148 | await Promise.all( 149 | Array.from(targetURLs.values()).map(async (normalizedURL) => { 150 | const expectedAlgorithms = ssri.algorithms ?? ['sha512']; 151 | const missingAlgorithms = expectedAlgorithms.filter( 152 | (alg) => !this.ssriLock[normalizedURL]?.[alg], 153 | ); 154 | 155 | const newIntegrity = 156 | missingAlgorithms.length > 0 157 | ? await this.limit(() => 158 | this.computeSSRI(normalizedURL, missingAlgorithms, { 159 | strict: ssri.strict, 160 | }), 161 | ) 162 | : null; 163 | 164 | if (newIntegrity) { 165 | if (this.ssriLock[normalizedURL]) { 166 | newIntegrity.merge(this.ssriLock[normalizedURL]); 167 | } 168 | 169 | this.ssriLock[normalizedURL] = newIntegrity; 170 | this.ssriLockDirty = true; 171 | } 172 | }), 173 | ); 174 | 175 | return { 176 | ...headers, 177 | ...this.patchHeaders(headers, this.ssriLock), 178 | }; 179 | }); 180 | } 181 | 182 | private getSSRILockFile({ lock = true }: SSRIFeatureOptions = {}): 183 | | string 184 | | undefined { 185 | return typeof lock === 'string' 186 | ? lock 187 | : lock 188 | ? 'ssri-lock.json' 189 | : undefined; 190 | } 191 | 192 | private getTargetURLs( 193 | headers: HeadersProps, 194 | options: Pick, 195 | ): string[] { 196 | const urls: string[] = []; 197 | 198 | if (headers.require !== undefined) { 199 | const requireURLs = ( 200 | Array.isArray(headers.require) ? headers.require : [headers.require] 201 | ).filter((url): url is string => typeof url === 'string'); 202 | 203 | for (const urlStr of requireURLs) { 204 | const url = this.parseURL(urlStr); 205 | if (this.filterURL(url, 'require', options)) { 206 | urls.push(this.stringifyURL(url)); 207 | } 208 | } 209 | } 210 | 211 | if (headers.resource !== undefined) { 212 | for (const urlStr of Object.values(headers.resource).filter( 213 | (url): url is string => typeof url === 'string', 214 | )) { 215 | const url = this.parseURL(urlStr); 216 | if (this.filterURL(url, 'resource', options)) { 217 | urls.push(this.stringifyURL(url)); 218 | } 219 | } 220 | } 221 | 222 | return urls; 223 | } 224 | 225 | private normalizeURL(url: string): string { 226 | const u = new URL('', this.parseURL(url)); 227 | u.hash = ''; 228 | 229 | return u.toString(); 230 | } 231 | 232 | private filterURL( 233 | url: URL, 234 | tag: SSRITag, 235 | { include, exclude }: Pick = {}, 236 | ): boolean { 237 | if (!this.allowedProtocols.has(url.protocol)) { 238 | return false; 239 | } 240 | 241 | if (include && !include(tag, url)) { 242 | return false; 243 | } 244 | 245 | if (exclude && exclude(tag, url)) { 246 | return false; 247 | } 248 | 249 | return true; 250 | } 251 | 252 | private async computeSSRI( 253 | url: string, 254 | algorithms: SSRIAlgorithm[], 255 | { strict }: SSRIFeatureOptions, 256 | ): Promise { 257 | const response = await fetch(url); 258 | 259 | if (response.status !== 200 || response.body === null) { 260 | throw new Error( 261 | `Failed to fetch SSRI sources. ` + 262 | `[${response.status} ${response.statusText}] ${url}`, 263 | ); 264 | } 265 | 266 | return await fromStream(response.body as Readable, { 267 | algorithms, 268 | strict, 269 | }); 270 | } 271 | 272 | private parseSSRILike(url: string): string { 273 | return this.parseURL(url).hash.slice(1).replace(/[,;]/g, ' '); 274 | } 275 | 276 | private parseSSRILock(rawSSRILock: RawSSRILock): SSRILock { 277 | return Object.fromEntries( 278 | Object.entries(rawSSRILock).map(([url, integrityLike]) => [ 279 | url, 280 | parseSSRI(integrityLike, { strict: true }), 281 | ]), 282 | ); 283 | } 284 | 285 | private toRawSSRILock(ssriLock: SSRILock): RawSSRILock { 286 | return Object.fromEntries( 287 | Object.entries(ssriLock).map( 288 | ([url, integrity]) => 289 | [url, stringifySSRI(integrity, { strict: true })] as const, 290 | ), 291 | ); 292 | } 293 | 294 | private parseURL(url: string): URL { 295 | return new URL(url); 296 | } 297 | 298 | private stringifyURL(url: URL): string { 299 | return url.toString(); 300 | } 301 | 302 | private updateURL(url: string, ssriLock: SSRILock): string { 303 | const integrity = ssriLock[url]; 304 | 305 | if (!integrity) return url; 306 | 307 | const urlObj = this.parseURL(url); 308 | urlObj.hash = '#' + stringifySSRI(integrity, { sep: ',', strict: true }); 309 | 310 | return urlObj.toString(); 311 | } 312 | 313 | private patchHeaders( 314 | headers: HeadersProps, 315 | ssriLock: SSRILock, 316 | ): HeadersProps { 317 | const headersProps: HeadersProps = {}; 318 | 319 | if (headers.require !== undefined) { 320 | if (Array.isArray(headers.require)) { 321 | headersProps.require = headers.require 322 | .filter((url): url is string => typeof url === 'string') 323 | .map((url) => this.updateURL(url, ssriLock)); 324 | } else { 325 | headersProps.require = this.updateURL(headers.require, ssriLock); 326 | } 327 | } 328 | 329 | if (headers.resource !== undefined) { 330 | headersProps.resource = Object.fromEntries( 331 | Object.entries(headers.resource) 332 | .filter( 333 | (entry): entry is [string, string] => typeof entry[1] === 'string', 334 | ) 335 | .map(([name, url]) => [name, this.updateURL(url, ssriLock)]), 336 | ); 337 | } 338 | 339 | return headersProps; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /lib/features/validate-headers/headers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompatibilityValue, 3 | InjectInto, 4 | MultiValue, 5 | NamedValue, 6 | RunAt, 7 | Sandbox, 8 | SingleValue, 9 | StrictHeadersProps, 10 | SwitchValue, 11 | } from '../../types'; 12 | import { 13 | IsDefined, 14 | IsEnumValue, 15 | IsMultiValue, 16 | IsNamedValue, 17 | IsNestedValue, 18 | IsOptional, 19 | IsSingleValue, 20 | IsSwitchValue, 21 | IsUnique, 22 | IsURLValue, 23 | partialGroups, 24 | } from './utils'; 25 | 26 | export enum ValidationGroup { 27 | Main = 'main', 28 | I18n = 'i18n', 29 | } 30 | 31 | export const Main = partialGroups(ValidationGroup.Main); 32 | export const I18n = partialGroups(ValidationGroup.I18n); 33 | export const Always = partialGroups(ValidationGroup.Main, ValidationGroup.I18n); 34 | 35 | export class Compatibility implements CompatibilityValue { 36 | [x: string]: SingleValue; 37 | 38 | @Main(IsOptional(), IsSingleValue()) 39 | public readonly firefox?: SingleValue; 40 | 41 | @Main(IsOptional(), IsSingleValue()) 42 | public readonly chrome?: SingleValue; 43 | 44 | @Main(IsOptional(), IsSingleValue()) 45 | public readonly opera?: SingleValue; 46 | 47 | @Main(IsOptional(), IsSingleValue()) 48 | public readonly safari?: SingleValue; 49 | 50 | @Main(IsOptional(), IsSingleValue()) 51 | public readonly edge?: SingleValue; 52 | } 53 | 54 | export class Headers implements StrictHeadersProps { 55 | @Main(IsDefined(), IsSingleValue()) 56 | @I18n(IsOptional(), IsSingleValue()) 57 | public readonly name?: SingleValue; 58 | 59 | @Main(IsOptional(), IsSingleValue()) 60 | public readonly version?: SingleValue; 61 | 62 | @Main(IsOptional(), IsSingleValue()) 63 | public readonly namespace?: SingleValue; 64 | 65 | @Main(IsOptional(), IsSingleValue()) 66 | public readonly author?: SingleValue; 67 | 68 | @Always(IsOptional(), IsSingleValue()) 69 | public readonly description?: SingleValue; 70 | 71 | @Main(IsOptional(), IsURLValue(), IsUnique('homepage')) 72 | public readonly homepage?: SingleValue; 73 | 74 | @Main(IsOptional(), IsURLValue(), IsUnique('homepage')) 75 | public readonly homepageURL?: SingleValue; 76 | 77 | @Main(IsOptional(), IsURLValue(), IsUnique('homepage')) 78 | public readonly website?: SingleValue; 79 | 80 | @Main(IsOptional(), IsURLValue(), IsUnique('homepage')) 81 | public readonly source?: SingleValue; 82 | 83 | @Main(IsOptional(), IsURLValue(), IsUnique('icon')) 84 | public readonly icon?: SingleValue; 85 | 86 | @Main(IsOptional(), IsURLValue(), IsUnique('icon')) 87 | public readonly iconURL?: SingleValue; 88 | 89 | @Main(IsOptional(), IsURLValue(), IsUnique('icon')) 90 | public readonly defaulticon?: SingleValue; 91 | 92 | @Main(IsOptional(), IsURLValue(), IsUnique('icon64')) 93 | public readonly icon64?: SingleValue; 94 | 95 | @Main(IsOptional(), IsURLValue(), IsUnique('icon64')) 96 | public readonly icon64URL?: SingleValue; 97 | 98 | @Main(IsOptional(), IsURLValue()) 99 | public readonly updateURL?: SingleValue; 100 | 101 | @Main(IsOptional(), IsURLValue(), IsUnique('downloadURL')) 102 | public readonly downloadURL?: SingleValue; 103 | 104 | @Main(IsOptional(), IsURLValue(), IsUnique('downloadURL')) 105 | public readonly installURL?: SingleValue; 106 | 107 | @Main(IsOptional(), IsURLValue()) 108 | public readonly supportURL?: SingleValue; 109 | 110 | @Main(IsOptional(), IsMultiValue()) 111 | public readonly include?: MultiValue; 112 | 113 | @Main(IsOptional(), IsMultiValue()) 114 | public readonly match?: MultiValue; 115 | 116 | @Main(IsOptional(), IsMultiValue()) 117 | public readonly 'exclude-match'?: MultiValue; 118 | 119 | @Main(IsOptional(), IsMultiValue()) 120 | public readonly exclude?: MultiValue; 121 | 122 | @Main(IsOptional(), IsMultiValue()) 123 | public readonly require?: MultiValue; 124 | 125 | @Main(IsOptional(), IsNamedValue()) 126 | public readonly resource?: NamedValue; 127 | 128 | @Main(IsOptional(), IsMultiValue()) 129 | public readonly connect?: MultiValue; 130 | 131 | @Main(IsOptional(), IsMultiValue()) 132 | public readonly grant?: MultiValue; 133 | 134 | @Main(IsOptional(), IsMultiValue()) 135 | public readonly webRequest?: MultiValue; 136 | 137 | @Main(IsOptional(), IsSwitchValue()) 138 | public readonly noframes?: SwitchValue; 139 | 140 | @Main(IsOptional(), IsSwitchValue()) 141 | public readonly unwrap?: SwitchValue; 142 | 143 | @Always(IsOptional(), IsNamedValue()) 144 | public readonly antifeature?: NamedValue; 145 | 146 | @Main(IsOptional(), IsEnumValue(RunAt)) 147 | public readonly 'run-at'?: RunAt; 148 | 149 | @Main(IsOptional(), IsSingleValue()) 150 | public readonly copyright?: SingleValue; 151 | 152 | @Main(IsOptional(), IsEnumValue(Sandbox)) 153 | public readonly sandbox?: Sandbox; 154 | 155 | @Main(IsOptional(), IsEnumValue(InjectInto)) 156 | public readonly 'inject-into'?: InjectInto; 157 | 158 | @Main(IsOptional(), IsSingleValue()) 159 | public readonly license?: SingleValue; 160 | 161 | @Main(IsOptional(), IsURLValue()) 162 | public readonly contributionURL?: SingleValue; 163 | 164 | @Main(IsOptional(), IsSingleValue()) 165 | public readonly contributionAmount?: SingleValue; 166 | 167 | @Main(IsOptional(), IsNestedValue(Compatibility)) 168 | public readonly compatible?: Compatibility; 169 | 170 | @Main(IsOptional(), IsNestedValue(Compatibility)) 171 | public readonly incompatible?: Compatibility; 172 | } 173 | -------------------------------------------------------------------------------- /lib/features/validate-headers/impl.ts: -------------------------------------------------------------------------------- 1 | import { instanceToPlain, plainToInstance } from 'class-transformer'; 2 | import { validateSync } from 'class-validator'; 3 | 4 | import { DEFAULT_LOCALE_KEY } from '../../const'; 5 | import { HeadersProps, UserscriptPluginInstance } from '../../types'; 6 | import { Feature } from '../feature'; 7 | import { Headers, ValidationGroup } from './headers'; 8 | 9 | export interface HeadersValidatorOptions { 10 | strict?: boolean; 11 | whitelist?: boolean; 12 | } 13 | 14 | export type HeaderClass = { new (): object }; 15 | 16 | export interface ValidateHeadersOptions extends HeadersValidatorOptions { 17 | proxyScript?: unknown; 18 | headersClass?: HeaderClass; 19 | } 20 | 21 | export class ValidateHeaders extends Feature { 22 | public readonly name = 'ValidateHeaders'; 23 | 24 | public apply({ hooks }: UserscriptPluginInstance): void { 25 | const HeadersClass = this.options.headersClass ?? Headers; 26 | 27 | hooks.headers.tap(this.name, (headersProps, { locale }) => 28 | this.validateHeaders(locale, headersProps, HeadersClass, this.options), 29 | ); 30 | 31 | if (this.options.proxyScript) { 32 | hooks.proxyHeaders.tap(this.name, (headersProps, { locale }) => 33 | this.validateHeaders(locale, headersProps, HeadersClass, this.options), 34 | ); 35 | } 36 | } 37 | 38 | private validateHeaders( 39 | locale: string, 40 | headersProps: HeadersProps, 41 | HeadersClass: HeaderClass, 42 | { whitelist, strict }: HeadersValidatorOptions = {}, 43 | ): HeadersProps { 44 | const validatorGroups = [ 45 | locale === DEFAULT_LOCALE_KEY 46 | ? ValidationGroup.Main 47 | : ValidationGroup.I18n, 48 | ]; 49 | 50 | const transformerGroups = whitelist 51 | ? validatorGroups 52 | : [ValidationGroup.Main, ValidationGroup.I18n]; 53 | 54 | const headers = plainToInstance(HeadersClass, headersProps, { 55 | exposeDefaultValues: true, 56 | excludeExtraneousValues: whitelist, 57 | exposeUnsetFields: false, 58 | groups: transformerGroups, 59 | }); 60 | 61 | if (strict) { 62 | const errors = validateSync(headers, { 63 | forbidNonWhitelisted: true, 64 | whitelist: true, 65 | stopAtFirstError: false, 66 | groups: validatorGroups, 67 | }); 68 | 69 | if (errors.length > 0) { 70 | throw new Error( 71 | `Validation groups: ${validatorGroups}\n` + 72 | errors 73 | .map((err) => err.toString(undefined, undefined, undefined, true)) 74 | .join('\n'), 75 | ); 76 | } 77 | } 78 | 79 | return instanceToPlain(headers, { 80 | exposeUnsetFields: false, 81 | groups: transformerGroups, 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/features/validate-headers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './headers'; 2 | export * from './impl'; 3 | export * from './utils'; 4 | -------------------------------------------------------------------------------- /lib/features/validate-headers/utils.ts: -------------------------------------------------------------------------------- 1 | import * as transformer from 'class-transformer'; 2 | import { defaultMetadataStorage } from 'class-transformer/cjs/storage'; 3 | import * as validator from 'class-validator'; 4 | 5 | import { applyDecorators, IsRecord, MutuallyExclusive } from '../../utils'; 6 | 7 | export interface GroupsOptions { 8 | groups?: string[]; 9 | } 10 | 11 | export type Validator = (options?: GroupsOptions) => PropertyDecorator; 12 | export type ValidatorFactory = (...args: T) => Validator; 13 | 14 | /** 15 | * `@Expose()` from class-transformer is not stackable, 16 | * wrap it in a new `@Expose()` implementation to stack for `groups` options. 17 | */ 18 | export const Expose: Validator = 19 | (options: transformer.ExposeOptions = {}) => 20 | (target, prop) => { 21 | const metadata = defaultMetadataStorage.findExposeMetadata( 22 | target.constructor, 23 | prop as string, 24 | ); 25 | 26 | if (!metadata) { 27 | transformer.Expose(options)(target, prop); 28 | 29 | return; 30 | } 31 | 32 | // merge expose options 33 | Object.assign( 34 | metadata.options, 35 | options, 36 | options.groups 37 | ? { 38 | groups: (metadata.options.groups ?? []).concat(options.groups), 39 | } 40 | : undefined, 41 | ); 42 | }; 43 | 44 | export const partialGroups = 45 | (...groups: string[]) => 46 | (...decorators: Validator[]): PropertyDecorator => 47 | applyDecorators(...decorators.map((deco) => deco({ groups }))); 48 | 49 | export const IsOptional: ValidatorFactory = () => validator.IsOptional; 50 | 51 | export const IsDefined: ValidatorFactory = () => validator.IsDefined; 52 | 53 | export const IsUnique: ValidatorFactory<[string]> = (name) => (options) => 54 | MutuallyExclusive(name, options); 55 | 56 | export const IsSingleValue: ValidatorFactory = () => (options) => 57 | applyDecorators(Expose(options), validator.IsString(options)); 58 | 59 | export const IsMultiValue: ValidatorFactory = () => (options) => 60 | applyDecorators( 61 | Expose(options), 62 | validator.IsString({ ...options, each: true }), 63 | ); 64 | 65 | export const IsURLValue: ValidatorFactory = () => (options) => 66 | applyDecorators(Expose(options), validator.IsUrl(undefined, options)); 67 | 68 | export const IsVersionValue: ValidatorFactory = () => (options) => 69 | applyDecorators(Expose(options), validator.IsSemVer(options)); 70 | 71 | export const IsSwitchValue: ValidatorFactory = () => (options) => 72 | applyDecorators(Expose(options), validator.IsBoolean(options)); 73 | 74 | export const IsNamedValue: ValidatorFactory = () => (options) => 75 | applyDecorators( 76 | Expose(options), 77 | IsRecord([validator.isString], [validator.isString], { 78 | ...options, 79 | message: ({ property }) => ` "${property}" is not a valid named value`, 80 | }), 81 | ); 82 | 83 | export const IsEnumValue: ValidatorFactory<[Record]> = 84 | (entity) => (options) => 85 | applyDecorators(Expose(options), validator.IsEnum(entity, options)); 86 | 87 | export const IsNestedValue: ValidatorFactory<[{ new (): object }]> = 88 | (clazz) => (options) => 89 | applyDecorators( 90 | Expose(options), 91 | transformer.Type(() => clazz), 92 | validator.ValidateNested(options), 93 | ); 94 | -------------------------------------------------------------------------------- /lib/fs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * FS-implementation aware functions. 3 | * @module 4 | */ 5 | import path from 'path'; 6 | import { promisify } from 'util'; 7 | 8 | export interface Stats { 9 | isFile: () => boolean; 10 | isDirectory: () => boolean; 11 | } 12 | 13 | export interface FsStat { 14 | stat(path: string, callback: (err: Error | null, stats: Stats) => void): void; 15 | } 16 | 17 | export interface FsReadFile { 18 | readFile( 19 | path: string, 20 | callback: (err: Error | null, content: Buffer) => void, 21 | ): void; 22 | } 23 | 24 | export interface FsWriteFile { 25 | writeFile( 26 | path: string, 27 | data: string | Buffer, 28 | callback: (err: Error | null) => void, 29 | ): void; 30 | } 31 | 32 | export interface FsMkdir { 33 | mkdir( 34 | path: string, 35 | options: { recursive?: boolean }, 36 | callback: (err: Error | null, path?: string) => void, 37 | ): void; 38 | } 39 | 40 | export async function findPackage(cwd: string, fs: FsStat): Promise { 41 | const statAsync = promisify(fs.stat); 42 | 43 | let dir = cwd; 44 | while (true) { 45 | const parent = path.dirname(dir); 46 | try { 47 | const pkg = await statAsync(path.join(dir, 'package.json')); 48 | if (pkg.isFile()) { 49 | return dir; 50 | } 51 | } catch (e) { 52 | // root directory 53 | if (dir === parent) { 54 | throw new Error(`package.json is not found`); 55 | } 56 | } 57 | dir = parent; 58 | } 59 | } 60 | 61 | export async function readJSON(file: string, fs: FsReadFile): Promise { 62 | const readFileAsync = promisify(fs.readFile); 63 | const buf = await readFileAsync(file); 64 | 65 | return JSON.parse(buf.toString('utf-8')); 66 | } 67 | 68 | export async function writeJSON( 69 | file: string, 70 | data: unknown, 71 | fs: FsWriteFile, 72 | ): Promise { 73 | const writeFileAsync = promisify(fs.writeFile); 74 | await writeFileAsync(file, Buffer.from(JSON.stringify(data), 'utf-8')); 75 | } 76 | 77 | export async function mkdirp( 78 | dir: string, 79 | fs: FsMkdir, 80 | ): Promise { 81 | const mkdirAsync = promisify(fs.mkdir); 82 | 83 | return await mkdirAsync(dir, { recursive: true }); 84 | } 85 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { UserscriptPlugin } from './plugin'; 4 | 5 | export default UserscriptPlugin; 6 | 7 | export { 8 | Feature, 9 | HeaderClass, 10 | HeadersProvider, 11 | LoadHeadersOptions, 12 | ProxyScriptFeatureOptions, 13 | ProxyScriptOptions, 14 | RenderHeadersOptions, 15 | ResolveBaseURLsOptions, 16 | SSRIAlgorithm, 17 | SSRIFeatureOptions, 18 | SSRIOptions, 19 | SSRITag, 20 | URLFilter, 21 | ValidateHeadersOptions, 22 | } from './features'; 23 | export * from './plugin'; 24 | export * from './types'; 25 | -------------------------------------------------------------------------------- /lib/plugin.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import { 4 | AsyncParallelHook, 5 | AsyncSeriesBailHook, 6 | AsyncSeriesWaterfallHook, 7 | } from 'tapable'; 8 | import { Compilation, Compiler, sources, WebpackPluginInstance } from 'webpack'; 9 | 10 | import { DEFAULT_LOCALE_KEY } from './const'; 11 | import { 12 | Feature, 13 | FixTags, 14 | Interpolater, 15 | LoadHeaders, 16 | LoadHeadersOptions, 17 | ProcessProxyScript, 18 | ProcessSSRI, 19 | ProxyScriptOptions, 20 | RenderHeaders, 21 | RenderHeadersOptions, 22 | ResolveBaseURLs, 23 | ResolveBaseURLsOptions, 24 | SetDefaultTags, 25 | SSRIOptions, 26 | ValidateHeaders, 27 | ValidateHeadersOptions, 28 | } from './features'; 29 | import { 30 | CompilationContext, 31 | FileInfo, 32 | HeadersProps, 33 | UserscriptPluginInstance, 34 | WaterfallContext, 35 | } from './types'; 36 | import { date } from './utils'; 37 | 38 | const { ConcatSource, RawSource } = sources; 39 | 40 | export interface UserscriptPluginOptions { 41 | metajs?: boolean; 42 | skip?: (fileInfo: FileInfo) => boolean; 43 | proxyScript?: unknown; 44 | i18n?: Record; 45 | } 46 | 47 | export type UserscriptOptions = LoadHeadersOptions & 48 | ResolveBaseURLsOptions & 49 | SSRIOptions & 50 | ProxyScriptOptions & 51 | RenderHeadersOptions & 52 | ValidateHeadersOptions & 53 | UserscriptPluginOptions; 54 | 55 | export class UserscriptPlugin 56 | implements WebpackPluginInstance, UserscriptPluginInstance 57 | { 58 | public readonly name = 'UserscriptPlugin'; 59 | 60 | public readonly features: Feature[]; 61 | 62 | public readonly hooks = { 63 | init: new AsyncParallelHook<[Compiler]>(['compiler']), 64 | close: new AsyncParallelHook<[Compiler]>(['compiler']), 65 | preprocess: new AsyncParallelHook<[Compilation, CompilationContext]>([ 66 | 'compilation', 67 | 'context', 68 | ]), 69 | process: new AsyncParallelHook<[Compilation, CompilationContext]>([ 70 | 'compilation', 71 | 'context', 72 | ]), 73 | headers: new AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>([ 74 | 'headersProps', 75 | 'context', 76 | ]), 77 | proxyHeaders: new AsyncSeriesWaterfallHook< 78 | [HeadersProps, WaterfallContext] 79 | >(['headersProps', 'context']), 80 | proxyScriptFile: new AsyncSeriesWaterfallHook<[string, WaterfallContext]>([ 81 | 'proxyScriptFile', 82 | 'context', 83 | ]), 84 | renderHeaders: new AsyncSeriesBailHook, string>([ 85 | 'headersProps', 86 | ]), 87 | renderProxyHeaders: new AsyncSeriesBailHook([ 88 | 'headersProps', 89 | ]), 90 | }; 91 | 92 | private readonly contexts = new WeakMap(); 93 | private options: UserscriptPluginOptions = {}; 94 | 95 | public constructor(options: UserscriptOptions = {}) { 96 | const { metajs = true, strict = true } = options; 97 | Object.assign(options, { metajs, strict } as UserscriptOptions); 98 | 99 | this.features = [ 100 | new LoadHeaders(options), 101 | new FixTags(options), 102 | new ResolveBaseURLs(options), 103 | new ProcessSSRI(options), 104 | new SetDefaultTags(options), 105 | new ProcessProxyScript(options), 106 | new Interpolater(options), 107 | new ValidateHeaders(options), 108 | new RenderHeaders(options), 109 | ]; 110 | 111 | this.options = options; 112 | } 113 | 114 | public apply(compiler: Compiler): void { 115 | const name = this.name; 116 | let buildNo = 0; 117 | 118 | const initPromise = new Promise((resolve) => 119 | queueMicrotask(() => resolve(this.init(compiler))), 120 | ); 121 | 122 | compiler.hooks.beforeCompile.tapPromise(name, () => initPromise); 123 | 124 | compiler.hooks.compilation.tap(name, (compilation) => { 125 | this.contexts.set(compilation, { 126 | buildNo: ++buildNo, 127 | buildTime: date(), 128 | fileInfo: [], 129 | }); 130 | 131 | compilation.hooks.processAssets.tapPromise( 132 | { 133 | name, 134 | stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS, 135 | }, 136 | () => this.preprocess(compilation), 137 | ); 138 | 139 | compilation.hooks.processAssets.tapPromise( 140 | { 141 | name, 142 | // we should generate userscript files 143 | // only if optimization of source files are complete 144 | stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, 145 | }, 146 | () => this.process(compilation), 147 | ); 148 | }); 149 | 150 | compiler.hooks.done.tapPromise(name, () => this.close(compiler)); 151 | 152 | for (const feature of this.features) { 153 | feature.apply(this); 154 | } 155 | } 156 | 157 | private async init(compiler: Compiler): Promise { 158 | await this.hooks.init.promise(compiler); 159 | } 160 | 161 | private async close(compiler: Compiler): Promise { 162 | await this.hooks.close.promise(compiler); 163 | } 164 | 165 | private async preprocess(compilation: Compilation): Promise { 166 | const context = this.contexts.get(compilation); 167 | 168 | /* istanbul ignore next */ 169 | if (!context) { 170 | return; 171 | } 172 | 173 | context.fileInfo = this.collectFileInfo(compilation); 174 | 175 | await this.hooks.preprocess.promise(compilation, context); 176 | } 177 | 178 | private async process(compilation: Compilation): Promise { 179 | const context = this.contexts.get(compilation); 180 | 181 | /* istanbul ignore next */ 182 | if (!context) { 183 | return; 184 | } 185 | 186 | await Promise.all( 187 | context.fileInfo.map((fileInfo) => 188 | this.emitUserscript(compilation, context, fileInfo), 189 | ), 190 | ); 191 | 192 | for (const { originalFile, userjsFile } of context.fileInfo) { 193 | if (originalFile !== userjsFile) { 194 | compilation.deleteAsset(originalFile); 195 | } 196 | } 197 | 198 | await this.hooks.process.promise(compilation, context); 199 | } 200 | 201 | private collectFileInfo(compilation: Compilation): FileInfo[] { 202 | const fileInfo: FileInfo[] = []; 203 | 204 | for (const entrypoint of compilation.entrypoints.values()) { 205 | const chunk = entrypoint.getEntrypointChunk(); 206 | for (const originalFile of chunk.files) { 207 | let q = originalFile.indexOf('?'); 208 | if (q < 0) { 209 | q = originalFile.length; 210 | } 211 | const filepath = originalFile.slice(0, q); 212 | const query = originalFile.slice(q); 213 | const dirname = path.dirname(filepath); 214 | const filename = path.basename(filepath); 215 | const basename = filepath.endsWith('.user.js') 216 | ? path.basename(filepath, '.user.js') 217 | : filepath.endsWith('.js') 218 | ? path.basename(filepath, '.js') 219 | : filepath; 220 | const extname = path.extname(filepath); 221 | 222 | const userjsFile = path.join(dirname, basename + '.user.js') + query; 223 | const metajsFile = path.join(dirname, basename + '.meta.js'); 224 | 225 | const fileInfoEntry = { 226 | chunk, 227 | originalFile, 228 | userjsFile, 229 | metajsFile, 230 | filename, 231 | dirname, 232 | basename, 233 | query, 234 | extname, 235 | }; 236 | 237 | if (this.options.skip?.(fileInfoEntry) ?? extname !== '.js') { 238 | continue; 239 | } 240 | 241 | fileInfo.push(fileInfoEntry); 242 | } 243 | } 244 | 245 | return fileInfo; 246 | } 247 | 248 | private async emitUserscript( 249 | compilation: Compilation, 250 | context: CompilationContext, 251 | fileInfo: FileInfo, 252 | ): Promise { 253 | const { metajs, proxyScript, i18n } = this.options; 254 | const { originalFile, chunk, metajsFile, userjsFile } = fileInfo; 255 | const sourceAsset = compilation.getAsset(originalFile); 256 | const waterfall = { 257 | ...context, 258 | fileInfo, 259 | compilation, 260 | }; 261 | 262 | if (!sourceAsset) { 263 | /* istanbul ignore next */ 264 | return; 265 | } 266 | 267 | const localizedHeaders = new Map(); 268 | 269 | const headers = await this.hooks.headers.promise( 270 | {}, 271 | { ...waterfall, locale: DEFAULT_LOCALE_KEY }, 272 | ); 273 | localizedHeaders.set(DEFAULT_LOCALE_KEY, headers); 274 | 275 | if (i18n) { 276 | await Promise.all( 277 | Object.keys(i18n).map(async (locale) => { 278 | localizedHeaders.set( 279 | locale, 280 | await this.hooks.headers.promise({}, { ...waterfall, locale }), 281 | ); 282 | }), 283 | ); 284 | } 285 | 286 | const headersStr = await this.hooks.renderHeaders.promise(localizedHeaders); 287 | 288 | const proxyHeaders = proxyScript 289 | ? await this.hooks.proxyHeaders.promise(headers, { 290 | ...waterfall, 291 | locale: DEFAULT_LOCALE_KEY, 292 | }) 293 | : undefined; 294 | const proxyScriptFile = proxyScript 295 | ? await this.hooks.proxyScriptFile.promise('', { 296 | ...waterfall, 297 | locale: DEFAULT_LOCALE_KEY, 298 | }) 299 | : undefined; 300 | 301 | const proxyHeadersStr = proxyHeaders 302 | ? await this.hooks.renderProxyHeaders.promise(proxyHeaders) 303 | : undefined; 304 | 305 | if (userjsFile !== originalFile) { 306 | compilation.emitAsset( 307 | userjsFile, 308 | new ConcatSource(headersStr, '\n', sourceAsset.source), 309 | { 310 | minimized: true, 311 | }, 312 | ); 313 | chunk.files.add(userjsFile); 314 | } else { 315 | compilation.updateAsset( 316 | userjsFile, 317 | new ConcatSource(headersStr, '\n', sourceAsset.source), 318 | { 319 | minimized: true, 320 | }, 321 | ); 322 | } 323 | 324 | if (metajs !== false) { 325 | compilation.emitAsset(metajsFile, new RawSource(headersStr), { 326 | minimized: true, 327 | }); 328 | chunk.auxiliaryFiles.add(metajsFile); 329 | } 330 | 331 | if (proxyScriptFile !== undefined && proxyHeadersStr !== undefined) { 332 | compilation.emitAsset(proxyScriptFile, new RawSource(proxyHeadersStr), { 333 | minimized: true, 334 | }); 335 | chunk.auxiliaryFiles.add(proxyScriptFile); 336 | } 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncParallelHook, 3 | AsyncSeriesBailHook, 4 | AsyncSeriesWaterfallHook, 5 | } from 'tapable'; 6 | import { Chunk, Compilation, Compiler } from 'webpack'; 7 | 8 | export type SingleValue = string | undefined; 9 | export type MultiValue = SingleValue | SingleValue[]; 10 | export type NamedValue = Record; 11 | export type SwitchValue = boolean; 12 | 13 | export type TagType = string; 14 | export type ValueType = 15 | | NamedValue 16 | | MultiValue 17 | | SingleValue 18 | | SwitchValue 19 | | undefined; 20 | 21 | export type EnumValue = T | `${T}`; 22 | 23 | export enum RunAt { 24 | DocumentStart = 'document-start', 25 | DocumentBody = 'document-body', 26 | DocumentEnd = 'document-end', 27 | DocumentIdle = 'document-idle', 28 | ContextMenu = 'context-menu', 29 | } 30 | 31 | export enum Sandbox { 32 | Raw = 'raw', 33 | JavaScript = 'JavaScript', 34 | DOM = 'DOM', 35 | } 36 | 37 | export enum InjectInto { 38 | Page = 'page', 39 | Content = 'content', 40 | Auto = 'auto', 41 | } 42 | 43 | export interface CompatibilityValue extends NamedValue { 44 | firefox?: string; 45 | chrome?: string; 46 | opera?: string; 47 | safari?: string; 48 | edge?: string; 49 | } 50 | 51 | export interface StrictHeadersProps { 52 | name?: SingleValue; 53 | version?: SingleValue; 54 | namespace?: SingleValue; 55 | author?: SingleValue; 56 | description?: SingleValue; 57 | homepage?: SingleValue; 58 | homepageURL?: SingleValue; 59 | website?: SingleValue; 60 | source?: SingleValue; 61 | icon?: SingleValue; 62 | iconURL?: SingleValue; 63 | defaulticon?: SingleValue; 64 | icon64?: SingleValue; 65 | icon64URL?: SingleValue; 66 | updateURL?: SingleValue; 67 | downloadURL?: SingleValue; 68 | installURL?: SingleValue; 69 | supportURL?: SingleValue; 70 | include?: MultiValue; 71 | match?: MultiValue; 72 | 'exclude-match'?: MultiValue; 73 | exclude?: MultiValue; 74 | require?: MultiValue; 75 | resource?: NamedValue; 76 | connect?: MultiValue; 77 | grant?: MultiValue; 78 | webRequest?: MultiValue; 79 | noframes?: SwitchValue; 80 | unwrap?: SwitchValue; 81 | antifeature?: NamedValue; 82 | 'run-at'?: EnumValue; 83 | copyright?: SingleValue; 84 | sandbox?: EnumValue; 85 | 'inject-into'?: EnumValue; 86 | license?: SingleValue; 87 | contributionURL?: SingleValue; 88 | contributionAmount?: SingleValue; 89 | compatible?: CompatibilityValue; 90 | incompatible?: CompatibilityValue; 91 | } 92 | 93 | export interface HeadersProps extends StrictHeadersProps { 94 | [tag: TagType]: ValueType; 95 | } 96 | 97 | export interface FileInfo { 98 | chunk: Chunk; 99 | originalFile: string; 100 | userjsFile: string; 101 | metajsFile: string; 102 | filename: string; 103 | basename: string; 104 | query: string; 105 | dirname: string; 106 | extname: string; 107 | } 108 | 109 | export interface CompilationContext { 110 | buildNo: number; 111 | buildTime: Date; 112 | fileInfo: FileInfo[]; 113 | } 114 | 115 | export interface WaterfallContext { 116 | buildNo: number; 117 | buildTime: Date; 118 | fileInfo: FileInfo; 119 | compilation: Compilation; 120 | locale: string; 121 | } 122 | 123 | export interface UserscriptPluginInstance { 124 | hooks: { 125 | init: AsyncParallelHook<[Compiler]>; 126 | close: AsyncParallelHook<[Compiler]>; 127 | preprocess: AsyncParallelHook<[Compilation, CompilationContext]>; 128 | process: AsyncParallelHook<[Compilation, CompilationContext]>; 129 | headers: AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>; 130 | proxyHeaders: AsyncSeriesWaterfallHook<[HeadersProps, WaterfallContext]>; 131 | proxyScriptFile: AsyncSeriesWaterfallHook<[string, WaterfallContext]>; 132 | renderHeaders: AsyncSeriesBailHook, string>; 133 | renderProxyHeaders: AsyncSeriesBailHook; 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | } from 'class-validator'; 6 | 7 | export function date(): Date { 8 | return new Date(); 9 | } 10 | 11 | /** 12 | * Shipped from NestJs#applyDecorators() 13 | * @see {@link https://github.com/nestjs/nest/blob/bee462e031f9562210c65b9eb8e8a20cab1f301f/packages/common/decorators/core/apply-decorators.ts github:nestjs/nest} 14 | */ 15 | export function applyDecorators( 16 | ...decorators: Array 17 | ) { 18 | return any, Y>( 19 | target: TFunction | object, 20 | propertyKey?: string | symbol, 21 | descriptor?: TypedPropertyDescriptor, 22 | ): void => { 23 | for (const decorator of decorators) { 24 | if (target instanceof Function && !descriptor) { 25 | (decorator as ClassDecorator)(target); 26 | 27 | continue; 28 | } 29 | (decorator as MethodDecorator | PropertyDecorator)( 30 | target, 31 | propertyKey as string | symbol, 32 | descriptor as TypedPropertyDescriptor, 33 | ); 34 | } 35 | }; 36 | } 37 | /** 38 | * @see {@link https://github.com/typestack/class-validator/issues/759#issuecomment-712361384 github:typestack/class-validator#759} 39 | */ 40 | export function MutuallyExclusive( 41 | group: string, 42 | validationOptions?: ValidationOptions, 43 | ): PropertyDecorator { 44 | const key = MutuallyExclusive.getMetaKey(group); 45 | 46 | return function (object: any, propertyName: string | symbol): void { 47 | const existing = Reflect.getMetadata(key, object) ?? []; 48 | 49 | Reflect.defineMetadata(key, [...existing, propertyName], object); 50 | 51 | registerDecorator({ 52 | name: 'MutuallyExclusive', 53 | target: object.constructor, 54 | propertyName: propertyName as string, 55 | constraints: [group], 56 | options: validationOptions, 57 | validator: { 58 | validate(_: any, args: ValidationArguments) { 59 | const mutuallyExclusiveProps: Array = Reflect.getMetadata( 60 | key, 61 | args.object, 62 | ); 63 | 64 | return ( 65 | mutuallyExclusiveProps.reduce( 66 | (p, c) => ((args.object as any)[c] !== undefined ? ++p : p), 67 | 0, 68 | ) === 1 69 | ); 70 | }, 71 | defaultMessage(validationArguments?: ValidationArguments) { 72 | if (!validationArguments) { 73 | return `Mutually exclusive group "${group}" is violated`; 74 | } 75 | 76 | const mutuallyExclusiveProps = ( 77 | Reflect.getMetadata(key, validationArguments.object) as string[] 78 | ).filter( 79 | (prop) => (validationArguments.object as any)[prop] !== undefined, 80 | ); 81 | 82 | const propsString = mutuallyExclusiveProps 83 | .map((p) => `"${p}"`) 84 | .join(', '); 85 | 86 | return ( 87 | `Mutually exclusive group "${group}" is violated, ` + 88 | `${propsString}.` 89 | ); 90 | }, 91 | }, 92 | }); 93 | }; 94 | } 95 | 96 | MutuallyExclusive.getMetaKey = (tag: string): symbol => 97 | Symbol.for(`custom:__@rst/validator_mutually_exclusive_${tag}__`); 98 | 99 | export function IsRecord( 100 | keyValidators: ((k: string | symbol) => boolean)[] = [], 101 | valueValidators: ((v: any) => boolean)[] = [], 102 | validationOptions?: ValidationOptions, 103 | ): PropertyDecorator { 104 | return function (object: any, propertyName: string | symbol): void { 105 | registerDecorator({ 106 | name: 'IsRecord', 107 | target: object.constructor, 108 | propertyName: propertyName as string, 109 | constraints: [], 110 | options: validationOptions, 111 | validator: { 112 | validate(value: any) { 113 | return ( 114 | typeof value === 'object' && 115 | Object.entries(value).every( 116 | ([k, v]) => 117 | keyValidators.every((validator) => validator(k)) && 118 | valueValidators.every((validator) => validator(v)), 119 | ) 120 | ); 121 | }, 122 | 123 | defaultMessage() { 124 | return 'record validation failed'; 125 | }, 126 | }, 127 | }); 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-userscript", 3 | "version": "3.2.3", 4 | "description": "A Webpack plugin for userscript projects.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/momocow/webpack-userscript.git" 8 | }, 9 | "author": "MomoCow ", 10 | "keywords": [ 11 | "webpack", 12 | "userscript", 13 | "tampermonkey", 14 | "greasemonkey" 15 | ], 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/momocow/webpack-userscript/issues" 19 | }, 20 | "homepage": "https://github.com/momocow/webpack-userscript#readme", 21 | "main": "./dist/index.js", 22 | "types": "./dist/index.d.ts", 23 | "files": [ 24 | "dist" 25 | ], 26 | "scripts": { 27 | "lint": "eslint lib/ --ext .js,.jsx,.ts,.tsx", 28 | "test": "jest --coverage --collectCoverageFrom \"lib/**/*.ts\"", 29 | "test:dev": "jest", 30 | "clean": "shx rm -rf dist", 31 | "ts-node": "ts-node", 32 | "docs": "typedoc", 33 | "build": "tsc -p tsconfig.build.json", 34 | "commit": "gitmoji -c", 35 | "prepare": "husky install", 36 | "release": "semantic-release" 37 | }, 38 | "devDependencies": { 39 | "@semantic-release/git": "^10.0.1", 40 | "@semantic-release/github": "^8.0.7", 41 | "@semantic-release/npm": "^10.0.3", 42 | "@types/express": "^4.17.17", 43 | "@types/jest": "^29.4.0", 44 | "@types/node": "^18.11.18", 45 | "@types/node-fetch": "^2.6.2", 46 | "@types/ssri": "^7.1.1", 47 | "@typescript-eslint/eslint-plugin": "^5.49.0", 48 | "@typescript-eslint/parser": "^5.49.0", 49 | "eslint": "^8.33.0", 50 | "eslint-config-prettier": "^8.6.0", 51 | "eslint-import-resolver-typescript": "^3.5.3", 52 | "eslint-plugin-import": "^2.27.5", 53 | "eslint-plugin-prettier": "^4.2.1", 54 | "eslint-plugin-simple-import-sort": "^10.0.0", 55 | "express": "^4.18.2", 56 | "gitmoji-cli": "^7.0.3", 57 | "husky": "^8.0.3", 58 | "jest": "29.4.1", 59 | "jest-extended": "^3.2.3", 60 | "lint-staged": "^13.1.0", 61 | "memfs": "^3.4.13", 62 | "prettier": "^2.8.3", 63 | "semantic-release": "^21.0.1", 64 | "semantic-release-gitmoji": "^1.6.4", 65 | "shx": "^0.3.4", 66 | "ts-jest": "^29.0.5", 67 | "ts-node": "^10.9.1", 68 | "typedoc": "^0.23.24", 69 | "typescript": "^4.9.4", 70 | "typescript-memoize": "^1.1.1", 71 | "webpack": "^5.75.0" 72 | }, 73 | "dependencies": { 74 | "class-transformer": "0.5.1", 75 | "class-validator": "^0.14.0", 76 | "node-fetch": "^2.6.9", 77 | "p-limit": "^3.1.0", 78 | "reflect-metadata": "^0.1.13", 79 | "ssri": "^10.0.1", 80 | "table": "^6.8.1", 81 | "tapable": "^2.2.1", 82 | "tslib": "^2.0.0" 83 | }, 84 | "peerDependencies": { 85 | "webpack": "5" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/integration/default-tags/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | public static readonly defaultMatchValue = '*://*/*'; 5 | 6 | public static readonly httpsMatchValue = 'https://*/*'; 7 | 8 | public static readonly httpsIncludeValue = 'https://*'; 9 | 10 | public static readonly customValue = '__custom__'; 11 | } 12 | -------------------------------------------------------------------------------- /test/integration/default-tags/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile, findTags } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('default tags', () => { 8 | let input: Volume; 9 | 10 | const httpsMatchTags = findTags.bind( 11 | undefined, 12 | 'match', 13 | Fixtures.httpsMatchValue, 14 | ); 15 | const httpsIncludeTags = findTags.bind( 16 | undefined, 17 | 'include', 18 | Fixtures.httpsIncludeValue, 19 | ); 20 | 21 | const defaultMatchTags = findTags.bind( 22 | undefined, 23 | 'match', 24 | Fixtures.defaultMatchValue, 25 | ); 26 | 27 | beforeEach(async () => { 28 | input = Volume.fromJSON({ 29 | '/entry.js': Fixtures.entryJs, 30 | '/package.json': Fixtures.packageJson, 31 | }); 32 | }); 33 | 34 | // eslint-disable-next-line max-len 35 | it('should use default match if no include or match specified', async () => { 36 | const output = await compile(input, { 37 | ...Fixtures.webpackConfig, 38 | plugins: [new UserscriptPlugin()], 39 | }); 40 | 41 | const userJs = output 42 | .readFileSync('/dist/output.user.js') 43 | .toString('utf-8'); 44 | const metaJs = output 45 | .readFileSync('/dist/output.meta.js') 46 | .toString('utf-8'); 47 | 48 | expect(defaultMatchTags(userJs)).toHaveLength(1); 49 | expect(defaultMatchTags(metaJs)).toHaveLength(1); 50 | }); 51 | 52 | it('should not use default match if include is provided', async () => { 53 | const output = await compile(input, { 54 | ...Fixtures.webpackConfig, 55 | plugins: [ 56 | new UserscriptPlugin({ 57 | headers: { 58 | include: Fixtures.httpsIncludeValue, 59 | }, 60 | }), 61 | ], 62 | }); 63 | 64 | const userJs = output 65 | .readFileSync('/dist/output.user.js') 66 | .toString('utf-8'); 67 | const metaJs = output 68 | .readFileSync('/dist/output.meta.js') 69 | .toString('utf-8'); 70 | 71 | expect(defaultMatchTags(userJs)).toHaveLength(0); 72 | expect(httpsIncludeTags(userJs)).toHaveLength(1); 73 | 74 | expect(defaultMatchTags(metaJs)).toHaveLength(0); 75 | expect(httpsIncludeTags(metaJs)).toHaveLength(1); 76 | }); 77 | 78 | it('should not use default match if match is provided', async () => { 79 | const output = await compile(input, { 80 | ...Fixtures.webpackConfig, 81 | plugins: [ 82 | new UserscriptPlugin({ 83 | headers: { 84 | match: Fixtures.httpsMatchValue, 85 | }, 86 | }), 87 | ], 88 | }); 89 | 90 | const userJs = output 91 | .readFileSync('/dist/output.user.js') 92 | .toString('utf-8'); 93 | const metaJs = output 94 | .readFileSync('/dist/output.meta.js') 95 | .toString('utf-8'); 96 | 97 | expect(defaultMatchTags(userJs)).toHaveLength(0); 98 | expect(httpsMatchTags(userJs)).toHaveLength(1); 99 | 100 | expect(defaultMatchTags(metaJs)).toHaveLength(0); 101 | expect(httpsMatchTags(metaJs)).toHaveLength(1); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/integration/fix-tags/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { File, GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | @File(__dirname, 'headers.txt') 5 | public static readonly headers: string; 6 | } 7 | -------------------------------------------------------------------------------- /test/integration/fix-tags/headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @downloadURL http://example.com 7 | // ==/UserScript== 8 | -------------------------------------------------------------------------------- /test/integration/fix-tags/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('fix-tags', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry.js': Fixtures.entryJs, 13 | '/package.json': Fixtures.packageJson, 14 | }); 15 | }); 16 | 17 | it('should fix tag names', async () => { 18 | const output = await compile(input, { 19 | ...Fixtures.webpackConfig, 20 | plugins: [ 21 | new UserscriptPlugin({ 22 | headers: { downloadUrl: 'http://example.com' }, 23 | strict: false, 24 | }), 25 | ], 26 | }); 27 | 28 | expect(output.toJSON()).toEqual({ 29 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers), 30 | '/dist/output.meta.js': Fixtures.headers, 31 | }); 32 | }); 33 | 34 | it('should throw on ambiguous tag names', () => { 35 | const promise = compile(input, { 36 | ...Fixtures.webpackConfig, 37 | plugins: [ 38 | new UserscriptPlugin({ 39 | headers: { 40 | downloadUrl: 'http://1.example.com', 41 | downloadURL: 'http://2.example.com', 42 | }, 43 | strict: false, 44 | }), 45 | ], 46 | }); 47 | 48 | return expect(promise).toReject(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/integration/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import { Configuration } from 'webpack'; 5 | 6 | export const FIXTURES_DIR = path.join(__dirname, 'fixtures'); 7 | 8 | export const File = 9 | (...paths: string[]): PropertyDecorator => 10 | (target, prop) => { 11 | Object.defineProperty(target, prop, { 12 | value: readFileSync(path.join(...paths), 'utf-8'), 13 | enumerable: true, 14 | configurable: false, 15 | writable: false, 16 | }); 17 | }; 18 | 19 | export class GlobalFixtures { 20 | @File(FIXTURES_DIR, 'entry.js.txt') 21 | public static readonly entryJs: string; 22 | 23 | @File(FIXTURES_DIR, 'entry.min.js.txt') 24 | public static readonly entryMinJs: string; 25 | 26 | @File(FIXTURES_DIR, 'headers.txt') 27 | public static readonly headers: string; 28 | 29 | @File(FIXTURES_DIR, 'package.json.txt') 30 | public static readonly packageJson: string; 31 | 32 | public static readonly webpackConfig: Configuration = Object.seal({ 33 | context: '/', 34 | mode: 'production', 35 | entry: '/entry.js', 36 | output: { 37 | path: '/dist', 38 | filename: 'output.js', 39 | }, 40 | }); 41 | 42 | public static entryUserJs(headers: string): string { 43 | return headers + '\n' + this.entryMinJs; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/integration/fixtures/entry.js.txt: -------------------------------------------------------------------------------- 1 | (function () { 2 | console.log('hello'); 3 | })(); 4 | -------------------------------------------------------------------------------- /test/integration/fixtures/entry.min.js.txt: -------------------------------------------------------------------------------- 1 | console.log("hello"); -------------------------------------------------------------------------------- /test/integration/fixtures/headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // ==/UserScript== 7 | -------------------------------------------------------------------------------- /test/integration/fixtures/package.json.txt: -------------------------------------------------------------------------------- 1 | { 2 | "name": "userscript", 3 | "version": "0.0.0", 4 | "description": "this is a fantastic userscript", 5 | "someUnrelatedProperty": true 6 | } -------------------------------------------------------------------------------- /test/integration/general/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures {} 4 | -------------------------------------------------------------------------------- /test/integration/general/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('general', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry.js': Fixtures.entryJs, 13 | '/package.json': Fixtures.packageJson, 14 | }); 15 | }); 16 | 17 | it('should successfully compile with default options', async () => { 18 | const output = await compile(input, { 19 | ...Fixtures.webpackConfig, 20 | plugins: [new UserscriptPlugin()], 21 | }); 22 | 23 | expect(output.toJSON()).toEqual({ 24 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers), 25 | '/dist/output.meta.js': Fixtures.headers, 26 | }); 27 | }); 28 | 29 | it('should skip files based on the skip option', async () => { 30 | const output = await compile(input, { 31 | ...Fixtures.webpackConfig, 32 | plugins: [ 33 | new UserscriptPlugin({ 34 | skip: (): boolean => true, 35 | }), 36 | ], 37 | }); 38 | 39 | expect(output.toJSON()).toEqual({ 40 | '/dist/output.js': Fixtures.entryMinJs, 41 | }); 42 | }); 43 | 44 | // eslint-disable-next-line max-len 45 | it('should successfully compile even the file extension is .user.js', async () => { 46 | input.renameSync('/entry.js', '/entry.user.js'); 47 | 48 | const output = await compile(input, { 49 | ...Fixtures.webpackConfig, 50 | entry: '/entry.user.js', 51 | output: { 52 | path: '/dist', 53 | filename: 'output.user.js', 54 | }, 55 | plugins: [new UserscriptPlugin()], 56 | }); 57 | 58 | expect(output.toJSON()).toEqual({ 59 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers), 60 | '/dist/output.meta.js': Fixtures.headers, 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/integration/headers/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { HeadersProps, ValueType } from 'webpack-userscript'; 2 | 3 | import { File, GlobalFixtures } from '../fixtures'; 4 | 5 | interface TagSample { 6 | value: ValueType; 7 | expect: string | string[]; 8 | } 9 | 10 | interface TagCase { 11 | validValues: TagSample[]; 12 | invalidValues: Omit[]; 13 | } 14 | 15 | export class Fixtures extends GlobalFixtures { 16 | public static readonly customValue = '__custom__'; 17 | 18 | @File(__dirname, 'pretty-headers.txt') 19 | public static readonly prettyHeaders: string; 20 | 21 | @File(__dirname, 'tag-order-headers.txt') 22 | public static readonly tagOrderHeaders: string; 23 | 24 | public static readonly tagSamples: Record = { 25 | 'run-at': { 26 | validValues: [ 27 | { value: 'document-start', expect: 'document-start' }, 28 | { value: 'document-body', expect: 'document-body' }, 29 | { value: 'document-end', expect: 'document-end' }, 30 | { value: 'document-idle', expect: 'document-idle' }, 31 | { value: 'context-menu', expect: 'context-menu' }, 32 | ], 33 | invalidValues: [{ value: 'a' }], 34 | }, 35 | sandbox: { 36 | validValues: [ 37 | { value: 'raw', expect: 'raw' }, 38 | { value: 'JavaScript', expect: 'JavaScript' }, 39 | { value: 'DOM', expect: 'DOM' }, 40 | ], 41 | invalidValues: [{ value: 'a' }], 42 | }, 43 | 'inject-into': { 44 | validValues: [ 45 | { value: 'page', expect: 'page' }, 46 | { value: 'content', expect: 'content' }, 47 | { value: 'auto', expect: 'auto' }, 48 | ], 49 | invalidValues: [{ value: 'a' }], 50 | }, 51 | compatible: { 52 | validValues: [ 53 | { 54 | value: { 55 | firefox: 'compatible string', 56 | chrome: 'compatible string', 57 | opera: 'compatible string', 58 | safari: 'compatible string', 59 | edge: 'compatible string', 60 | }, 61 | expect: [ 62 | 'firefox compatible string', 63 | 'chrome compatible string', 64 | 'opera compatible string', 65 | 'safari compatible string', 66 | 'edge compatible string', 67 | ], 68 | }, 69 | ], 70 | invalidValues: [{ value: { unknownBrowser: 'compatible string' } }], 71 | }, 72 | incompatible: { 73 | validValues: [ 74 | { 75 | value: { 76 | firefox: 'compatible string', 77 | chrome: 'compatible string', 78 | opera: 'compatible string', 79 | safari: 'compatible string', 80 | edge: 'compatible string', 81 | }, 82 | expect: [ 83 | 'firefox compatible string', 84 | 'chrome compatible string', 85 | 'opera compatible string', 86 | 'safari compatible string', 87 | 'edge compatible string', 88 | ], 89 | }, 90 | ], 91 | invalidValues: [{ value: { unknownBrowser: 'incompatible string' } }], 92 | }, 93 | }; 94 | 95 | public static readonly mutuallyExclusiveTags: HeadersProps[] = [ 96 | { 97 | homepage: 'https://home.example.com', 98 | homepageURL: 'https://home.example.com', 99 | website: 'https://home.example.com', 100 | source: 'https://home.example.com', 101 | }, 102 | { 103 | icon: 'https://icon.example.com', 104 | iconURL: 'https://icon.example.com', 105 | defaulticon: 'https://icon.example.com', 106 | }, 107 | { 108 | downloadURL: 'https://download.example.com', 109 | installURL: 'https://install.example.com', 110 | }, 111 | ]; 112 | } 113 | -------------------------------------------------------------------------------- /test/integration/headers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptOptions, UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile, findTags } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('headers', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry.js': Fixtures.entryJs, 13 | '/package.json': Fixtures.packageJson, 14 | }); 15 | }); 16 | 17 | describe('validate-headers', () => { 18 | describe('strict & whiltelist', () => { 19 | const testCustomTags = 20 | ( 21 | count: number, 22 | { 23 | strict, 24 | whitelist, 25 | }: Pick = {}, 26 | ) => 27 | async (): Promise => { 28 | const customTags = findTags.bind( 29 | undefined, 30 | 'custom', 31 | Fixtures.customValue, 32 | ); 33 | 34 | const output = await compile(input, { 35 | ...Fixtures.webpackConfig, 36 | plugins: [ 37 | new UserscriptPlugin({ 38 | headers: { 39 | custom: Fixtures.customValue, 40 | }, 41 | strict, 42 | whitelist, 43 | }), 44 | ], 45 | }); 46 | 47 | const userJs = output 48 | .readFileSync('/dist/output.user.js') 49 | .toString('utf-8'); 50 | const metaJs = output 51 | .readFileSync('/dist/output.meta.js') 52 | .toString('utf-8'); 53 | 54 | expect(customTags(userJs)).toHaveLength(count); 55 | expect(customTags(metaJs)).toHaveLength(count); 56 | }; 57 | 58 | it('should throw for custom tags in strict but non-whitelist mode', () => 59 | expect( 60 | testCustomTags(0, { 61 | strict: true, 62 | whitelist: false, 63 | })(), 64 | ).toReject()); 65 | 66 | it( 67 | 'should render custom tags in non-strict and non-whitelist mode', 68 | testCustomTags(1, { 69 | strict: false, 70 | whitelist: false, 71 | }), 72 | ); 73 | 74 | it( 75 | 'should not render custom tags in non-strict but whitelist mode', 76 | testCustomTags(0, { 77 | strict: false, 78 | whitelist: true, 79 | }), 80 | ); 81 | 82 | it( 83 | 'should not render custom tags in strict and whitelist mode', 84 | testCustomTags(0, { 85 | strict: true, 86 | whitelist: true, 87 | }), 88 | ); 89 | }); 90 | 91 | describe('headersClass', () => { 92 | it('should use custom headersClass if provided', () => { 93 | class EmptyHeaders {} 94 | 95 | const promise = compile(input, { 96 | ...Fixtures.webpackConfig, 97 | plugins: [ 98 | new UserscriptPlugin({ 99 | headers: { 100 | custom: Fixtures.customValue, 101 | }, 102 | headersClass: EmptyHeaders, 103 | strict: true, 104 | whitelist: false, 105 | }), 106 | ], 107 | }); 108 | 109 | return expect(promise).toReject(); 110 | }); 111 | 112 | for (const headers of Fixtures.mutuallyExclusiveTags) { 113 | it( 114 | 'should throw error if mutually exclusive tags present: ' + 115 | Object.keys(headers).join(', '), 116 | () => { 117 | const promise = compile(input, { 118 | ...Fixtures.webpackConfig, 119 | plugins: [ 120 | new UserscriptPlugin({ 121 | headers, 122 | strict: true, 123 | }), 124 | ], 125 | }); 126 | 127 | return expect(promise).toReject(); 128 | }, 129 | ); 130 | } 131 | 132 | for (const [tag, { validValues, invalidValues }] of Object.entries( 133 | Fixtures.tagSamples, 134 | )) { 135 | describe(tag, () => { 136 | for (const { value, expect: expectedOutput } of validValues) { 137 | it('valid case: ' + JSON.stringify(value), async () => { 138 | const output = await compile(input, { 139 | ...Fixtures.webpackConfig, 140 | plugins: [ 141 | new UserscriptPlugin({ 142 | headers: { 143 | [tag]: value, 144 | }, 145 | }), 146 | ], 147 | }); 148 | 149 | const userJs = output 150 | .readFileSync('/dist/output.user.js') 151 | .toString('utf-8'); 152 | const metaJs = output 153 | .readFileSync('/dist/output.meta.js') 154 | .toString('utf-8'); 155 | 156 | const expectedOutputList = !Array.isArray(expectedOutput) 157 | ? [expectedOutput] 158 | : expectedOutput; 159 | for (const outputValue of expectedOutputList) { 160 | expect(findTags(tag, outputValue, userJs)).toHaveLength(1); 161 | expect(findTags(tag, outputValue, metaJs)).toHaveLength(1); 162 | } 163 | }); 164 | } 165 | 166 | for (const { value } of invalidValues) { 167 | it('invalid case: ' + JSON.stringify(value), () => { 168 | const promise = compile(input, { 169 | ...Fixtures.webpackConfig, 170 | plugins: [ 171 | new UserscriptPlugin({ 172 | headers: { 173 | [tag]: value, 174 | }, 175 | }), 176 | ], 177 | }); 178 | 179 | return expect(promise).toReject(); 180 | }); 181 | } 182 | }); 183 | } 184 | }); 185 | }); 186 | 187 | describe('render-headers', () => { 188 | it('should be rendered prettily', async () => { 189 | const output = await compile(input, { 190 | ...Fixtures.webpackConfig, 191 | plugins: [ 192 | new UserscriptPlugin({ 193 | headers: { 194 | resource: { 195 | test: 'http://example.com/demo.jpg', 196 | }, 197 | include: ['https://example.com/', 'http://example.com/'], 198 | noframes: true, 199 | unwrap: false, 200 | }, 201 | pretty: true, 202 | }), 203 | ], 204 | }); 205 | 206 | expect(output.toJSON()).toEqual({ 207 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.prettyHeaders), 208 | '/dist/output.meta.js': Fixtures.prettyHeaders, 209 | }); 210 | }); 211 | 212 | it('should respect the specified tag order', async () => { 213 | const output = await compile(input, { 214 | ...Fixtures.webpackConfig, 215 | plugins: [ 216 | new UserscriptPlugin({ 217 | // though "@include" tag does not present in the headers, 218 | // it is fine to be in the tagOrder list 219 | tagOrder: ['include', 'match', 'version', 'description', 'name'], 220 | }), 221 | ], 222 | }); 223 | 224 | expect(output.toJSON()).toEqual({ 225 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.tagOrderHeaders), 226 | '/dist/output.meta.js': Fixtures.tagOrderHeaders, 227 | }); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /test/integration/headers/pretty-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @include https://example.com/ 6 | // @include http://example.com/ 7 | // @noframes 8 | // @resource test http://example.com/demo.jpg 9 | // ==/UserScript== 10 | -------------------------------------------------------------------------------- /test/integration/headers/tag-order-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @match *://*/* 3 | // @version 0.0.0 4 | // @description this is a fantastic userscript 5 | // @name userscript 6 | // ==/UserScript== 7 | -------------------------------------------------------------------------------- /test/integration/i18n/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { File, GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | @File(__dirname, 'i18n.headers.txt') 5 | public static readonly i18nHeaders: string; 6 | 7 | @File(__dirname, 'non-strict-i18n.headers.txt') 8 | public static readonly nonStrictI18nHeaders: string; 9 | } 10 | -------------------------------------------------------------------------------- /test/integration/i18n/i18n.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name i18n 3 | // @name:en localized name 4 | // @description this is a fantastic userscript 5 | // @description:en i18n description 6 | // @version 0.0.0 7 | // @match *://*/* 8 | // ==/UserScript== 9 | -------------------------------------------------------------------------------- /test/integration/i18n/index.test.ts: -------------------------------------------------------------------------------- 1 | import { HeadersProps, UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('i18n', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry.js': Fixtures.entryJs, 13 | '/package.json': Fixtures.packageJson, 14 | }); 15 | }); 16 | 17 | it('headers object', async () => { 18 | const output = await compile(input, { 19 | ...Fixtures.webpackConfig, 20 | plugins: [ 21 | new UserscriptPlugin({ 22 | headers: { 23 | name: 'i18n', 24 | }, 25 | i18n: { 26 | en: { 27 | name: 'localized name', 28 | description: 'i18n description', 29 | }, 30 | }, 31 | }), 32 | ], 33 | }); 34 | 35 | expect(output.toJSON()).toEqual({ 36 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders), 37 | '/dist/output.meta.js': Fixtures.i18nHeaders, 38 | }); 39 | }); 40 | 41 | it('headers file', async () => { 42 | input.writeFileSync( 43 | '/headers.json', 44 | JSON.stringify({ 45 | name: 'localized name', 46 | description: 'i18n description', 47 | }), 48 | ); 49 | 50 | const output = await compile(input, { 51 | ...Fixtures.webpackConfig, 52 | plugins: [ 53 | new UserscriptPlugin({ 54 | headers: { 55 | name: 'i18n', 56 | }, 57 | i18n: { 58 | en: '/headers.json', 59 | }, 60 | }), 61 | ], 62 | }); 63 | 64 | expect(output.toJSON()).toEqual({ 65 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders), 66 | '/dist/output.meta.js': Fixtures.i18nHeaders, 67 | }); 68 | }); 69 | 70 | it('headers provider', async () => { 71 | const output = await compile(input, { 72 | ...Fixtures.webpackConfig, 73 | plugins: [ 74 | new UserscriptPlugin({ 75 | headers: { 76 | name: 'i18n', 77 | }, 78 | i18n: { 79 | en: (headers): HeadersProps => ({ 80 | ...headers, 81 | name: 'localized name', 82 | description: 'i18n description', 83 | }), 84 | }, 85 | }), 86 | ], 87 | }); 88 | 89 | expect(output.toJSON()).toEqual({ 90 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders), 91 | '/dist/output.meta.js': Fixtures.i18nHeaders, 92 | }); 93 | }); 94 | 95 | describe('unlocalizable tags', () => { 96 | it('are rejected in strict mode', () => { 97 | const promise = compile(input, { 98 | ...Fixtures.webpackConfig, 99 | plugins: [ 100 | new UserscriptPlugin({ 101 | headers: { 102 | name: 'i18n', 103 | }, 104 | i18n: { 105 | en: { 106 | name: 'localized name', 107 | downloadURL: 'https://example.com', 108 | }, 109 | }, 110 | }), 111 | ], 112 | }); 113 | 114 | return expect(promise).toReject(); 115 | }); 116 | 117 | it('are allowed in non-strict mode', async () => { 118 | const output = await compile(input, { 119 | ...Fixtures.webpackConfig, 120 | plugins: [ 121 | new UserscriptPlugin({ 122 | headers: { 123 | name: 'non-strict i18n', 124 | }, 125 | i18n: { 126 | en: (headers): HeadersProps => ({ 127 | ...headers, 128 | downloadURL: 'https://example.com', 129 | }), 130 | }, 131 | strict: false, 132 | }), 133 | ], 134 | }); 135 | 136 | expect(output.toJSON()).toEqual({ 137 | '/dist/output.user.js': Fixtures.entryUserJs( 138 | Fixtures.nonStrictI18nHeaders, 139 | ), 140 | '/dist/output.meta.js': Fixtures.nonStrictI18nHeaders, 141 | }); 142 | }); 143 | 144 | it('are stripped in whitelist mode', async () => { 145 | const output = await compile(input, { 146 | ...Fixtures.webpackConfig, 147 | plugins: [ 148 | new UserscriptPlugin({ 149 | headers: { 150 | name: 'i18n', 151 | }, 152 | i18n: { 153 | en: { 154 | name: 'localized name', 155 | description: 'i18n description', 156 | // downloadURL will be stripped 157 | downloadURL: 'https://example.com', 158 | }, 159 | }, 160 | whitelist: true, 161 | }), 162 | ], 163 | }); 164 | 165 | expect(output.toJSON()).toEqual({ 166 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.i18nHeaders), 167 | '/dist/output.meta.js': Fixtures.i18nHeaders, 168 | }); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/integration/i18n/non-strict-i18n.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name non-strict i18n 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @downloadURL:en https://example.com 7 | // ==/UserScript== 8 | -------------------------------------------------------------------------------- /test/integration/interpolater/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures {} 4 | -------------------------------------------------------------------------------- /test/integration/interpolater/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile, findTags } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | jest.mock('webpack-userscript/utils', () => ({ 8 | ...jest.requireActual('webpack-userscript/utils'), 9 | date: (): Date => new Date(0), 10 | })); 11 | 12 | describe('interpolater', () => { 13 | let input: Volume; 14 | 15 | beforeEach(async () => { 16 | input = Volume.fromJSON({ 17 | '/entry.js': Fixtures.entryJs, 18 | '/package.json': Fixtures.packageJson, 19 | }); 20 | }); 21 | 22 | for (const [name, expectedName] of [ 23 | ['[name]', 'customEntry'], 24 | ['[file]', 'output.js'], 25 | ['[filename]', 'output.js'], 26 | ['[basename]', 'output'], 27 | ['[query]', ''], 28 | ['[dirname]', '.'], 29 | ['[buildNo]', '1'], 30 | ['[buildTime]', '1970-01-01T00:00:00.000Z'], 31 | ]) { 32 | it(name, async () => { 33 | const output = await compile(input, { 34 | ...Fixtures.webpackConfig, 35 | entry: { 36 | customEntry: '/entry.js', 37 | }, 38 | plugins: [ 39 | new UserscriptPlugin({ 40 | headers: { 41 | name, 42 | }, 43 | }), 44 | ], 45 | }); 46 | 47 | const userJs = output 48 | .readFileSync('/dist/output.user.js') 49 | .toString('utf-8'); 50 | const metaJs = output 51 | .readFileSync('/dist/output.meta.js') 52 | .toString('utf-8'); 53 | 54 | expect(findTags('name', expectedName, userJs)).toHaveLength(1); 55 | expect(findTags('name', expectedName, metaJs)).toHaveLength(1); 56 | }); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /test/integration/load-headers/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { Memoize } from 'typescript-memoize'; 2 | 3 | import { File, GlobalFixtures } from '../fixtures'; 4 | 5 | export class Fixtures extends GlobalFixtures { 6 | @Memoize() 7 | public static get headersJson(): string { 8 | return JSON.stringify({ 9 | name: 'load-headers', 10 | }); 11 | } 12 | 13 | @File(__dirname, 'load-headers.headers.txt') 14 | public static readonly loadHeadersHeaders: string; 15 | } 16 | -------------------------------------------------------------------------------- /test/integration/load-headers/index.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import { HeadersProps, UserscriptPlugin } from 'webpack-userscript'; 4 | import * as fs from 'webpack-userscript/fs'; 5 | 6 | import { compile, watchCompile } from '../util'; 7 | import { Volume } from '../volume'; 8 | import { Fixtures } from './fixtures'; 9 | 10 | describe('load-headers', () => { 11 | let input: Volume; 12 | 13 | beforeEach(async () => { 14 | input = Volume.fromJSON({ 15 | '/entry.js': Fixtures.entryJs, 16 | '/package.json': Fixtures.packageJson, 17 | }); 18 | }); 19 | 20 | it('can be loaded from headers object', async () => { 21 | const output = await compile(input, { 22 | ...Fixtures.webpackConfig, 23 | plugins: [ 24 | new UserscriptPlugin({ 25 | headers: { 26 | name: 'load-headers', 27 | }, 28 | }), 29 | ], 30 | }); 31 | 32 | expect(output.toJSON()).toEqual({ 33 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.loadHeadersHeaders), 34 | '/dist/output.meta.js': Fixtures.loadHeadersHeaders, 35 | }); 36 | }); 37 | 38 | describe('headers file', () => { 39 | it('can be loaded from headers file', async () => { 40 | input.writeFileSync('/headers.json', Fixtures.headersJson); 41 | 42 | const output = await compile(input, { 43 | ...Fixtures.webpackConfig, 44 | plugins: [ 45 | new UserscriptPlugin({ 46 | headers: '/headers.json', 47 | }), 48 | ], 49 | }); 50 | 51 | expect(output.toJSON()).toEqual({ 52 | '/dist/output.user.js': Fixtures.entryUserJs( 53 | Fixtures.loadHeadersHeaders, 54 | ), 55 | '/dist/output.meta.js': Fixtures.loadHeadersHeaders, 56 | }); 57 | }); 58 | 59 | // eslint-disable-next-line max-len 60 | it('should throw error if headers file is not in .json format', async () => { 61 | input.writeFileSync('/headers.json', '{"name": "invalid-json",'); 62 | 63 | const promise = compile(input, { 64 | ...Fixtures.webpackConfig, 65 | plugins: [ 66 | new UserscriptPlugin({ 67 | headers: '/headers.json', 68 | }), 69 | ], 70 | }); 71 | 72 | await expect(promise).toReject(); 73 | }); 74 | 75 | // eslint-disable-next-line max-len 76 | it('should reuse from headers file if the file is not changed', async () => { 77 | input.writeFileSync('/headers.json', Fixtures.headersJson); 78 | 79 | const plugin = new UserscriptPlugin({ 80 | headers: './headers.json', 81 | }); 82 | 83 | const readJSONSpy = jest.spyOn(fs, 'readJSON'); 84 | 85 | const entry = './entry.js'; 86 | let step = 0; 87 | let inputFullPath = ''; 88 | 89 | await watchCompile( 90 | input, 91 | { 92 | ...Fixtures.webpackConfig, 93 | context: '/', 94 | entry, 95 | plugins: [plugin], 96 | }, 97 | async ({ output, writeFile, cwd }) => { 98 | switch (++step) { 99 | case 1: 100 | expect(output.toJSON()).toEqual({ 101 | '/dist/output.user.js': Fixtures.entryUserJs( 102 | Fixtures.loadHeadersHeaders, 103 | ), 104 | '/dist/output.meta.js': Fixtures.loadHeadersHeaders, 105 | }); 106 | await writeFile(entry, Fixtures.entryJs); 107 | break; 108 | 109 | case 2: 110 | break; 111 | 112 | default: 113 | fail('invalid steps'); 114 | } 115 | 116 | inputFullPath = cwd; 117 | 118 | return step < 2; 119 | }, 120 | ); 121 | 122 | if (step !== 2) { 123 | fail('invalid steps'); 124 | } 125 | 126 | const headersJsonPath = path.join(inputFullPath, 'headers.json'); 127 | 128 | // headers.json has only been read once 129 | expect( 130 | readJSONSpy.mock.calls.reduce( 131 | (count, call) => (call[0] === headersJsonPath ? ++count : count), 132 | 0, 133 | ), 134 | ).toEqual(1); 135 | 136 | readJSONSpy.mockRestore(); 137 | }); 138 | }); 139 | 140 | it('should compile if package.json does not exist', async () => { 141 | input.rmSync('/package.json'); 142 | 143 | const output = await compile(input, { 144 | ...Fixtures.webpackConfig, 145 | plugins: [ 146 | new UserscriptPlugin({ 147 | headers: { 148 | name: 'userscript', 149 | version: '0.0.0', 150 | description: 'this is a fantastic userscript', 151 | }, 152 | }), 153 | ], 154 | }); 155 | 156 | expect(output.toJSON()).toEqual({ 157 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers), 158 | '/dist/output.meta.js': Fixtures.headers, 159 | }); 160 | }); 161 | 162 | describe('headers provider', () => { 163 | it('can be loaded from headers provider function', async () => { 164 | const output = await compile(input, { 165 | ...Fixtures.webpackConfig, 166 | plugins: [ 167 | new UserscriptPlugin({ 168 | headers: (headers): HeadersProps => ({ 169 | ...headers, 170 | name: 'load-headers', 171 | }), 172 | }), 173 | ], 174 | }); 175 | 176 | expect(output.toJSON()).toEqual({ 177 | '/dist/output.user.js': Fixtures.entryUserJs( 178 | Fixtures.loadHeadersHeaders, 179 | ), 180 | '/dist/output.meta.js': Fixtures.loadHeadersHeaders, 181 | }); 182 | }); 183 | 184 | it('can be loaded from async headers provider function', async () => { 185 | const output = await compile(input, { 186 | ...Fixtures.webpackConfig, 187 | plugins: [ 188 | new UserscriptPlugin({ 189 | headers: async (headers): Promise => ({ 190 | ...headers, 191 | name: 'load-headers', 192 | }), 193 | }), 194 | ], 195 | }); 196 | 197 | expect(output.toJSON()).toEqual({ 198 | '/dist/output.user.js': Fixtures.entryUserJs( 199 | Fixtures.loadHeadersHeaders, 200 | ), 201 | '/dist/output.meta.js': Fixtures.loadHeadersHeaders, 202 | }); 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /test/integration/load-headers/load-headers.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name load-headers 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // ==/UserScript== 7 | -------------------------------------------------------------------------------- /test/integration/multi-entry/entry1.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name entry1 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // ==/UserScript== 7 | -------------------------------------------------------------------------------- /test/integration/multi-entry/entry2.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name entry2 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // ==/UserScript== 7 | -------------------------------------------------------------------------------- /test/integration/multi-entry/entry3.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name entry3 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // ==/UserScript== 7 | -------------------------------------------------------------------------------- /test/integration/multi-entry/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { File, GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | @File(__dirname, 'entry1.headers.txt') 5 | public static readonly entry1Headers: string; 6 | 7 | @File(__dirname, 'entry2.headers.txt') 8 | public static readonly entry2Headers: string; 9 | 10 | @File(__dirname, 'entry3.headers.txt') 11 | public static readonly entry3Headers: string; 12 | } 13 | -------------------------------------------------------------------------------- /test/integration/multi-entry/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('multi-entry', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry1.js': Fixtures.entryJs, 13 | '/entry2.js': Fixtures.entryJs, 14 | '/entry3.js': Fixtures.entryJs, 15 | '/package.json': Fixtures.packageJson, 16 | }); 17 | }); 18 | 19 | it('should call headers provider against each entry', async () => { 20 | const headersProvider = jest.fn().mockImplementation( 21 | ( 22 | headers, 23 | { 24 | fileInfo: { 25 | chunk: { name }, 26 | }, 27 | }, 28 | ) => ({ ...headers, name }), 29 | ); 30 | 31 | const output = await compile(input, { 32 | ...Fixtures.webpackConfig, 33 | entry: { 34 | entry1: '/entry1.js', 35 | entry2: '/entry2.js', 36 | entry3: '/entry3.js', 37 | }, 38 | output: { 39 | path: '/dist', 40 | filename: '[name].js', 41 | }, 42 | plugins: [ 43 | new UserscriptPlugin({ 44 | headers: headersProvider, 45 | }), 46 | ], 47 | }); 48 | 49 | expect(headersProvider).toBeCalledTimes(3); 50 | 51 | expect(output.toJSON()).toEqual({ 52 | '/dist/entry1.user.js': Fixtures.entryUserJs(Fixtures.entry1Headers), 53 | '/dist/entry1.meta.js': Fixtures.entry1Headers, 54 | '/dist/entry2.user.js': Fixtures.entryUserJs(Fixtures.entry2Headers), 55 | '/dist/entry2.meta.js': Fixtures.entry2Headers, 56 | '/dist/entry3.user.js': Fixtures.entryUserJs(Fixtures.entry3Headers), 57 | '/dist/entry3.meta.js': Fixtures.entry3Headers, 58 | }); 59 | }); 60 | 61 | it('should interpolate headers against each entry', async () => { 62 | const output = await compile(input, { 63 | ...Fixtures.webpackConfig, 64 | entry: { 65 | entry1: '/entry1.js', 66 | entry2: '/entry2.js', 67 | entry3: '/entry3.js', 68 | }, 69 | output: { 70 | path: '/dist', 71 | filename: '[name].js', 72 | }, 73 | plugins: [ 74 | new UserscriptPlugin({ 75 | headers: { 76 | name: '[name]', 77 | }, 78 | }), 79 | ], 80 | }); 81 | 82 | expect(output.toJSON()).toEqual({ 83 | '/dist/entry1.user.js': Fixtures.entryUserJs(Fixtures.entry1Headers), 84 | '/dist/entry1.meta.js': Fixtures.entry1Headers, 85 | '/dist/entry2.user.js': Fixtures.entryUserJs(Fixtures.entry2Headers), 86 | '/dist/entry2.meta.js': Fixtures.entry2Headers, 87 | '/dist/entry3.user.js': Fixtures.entryUserJs(Fixtures.entry3Headers), 88 | '/dist/entry3.meta.js': Fixtures.entry3Headers, 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/integration/package-json/bugs.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name package-json-bugs 3 | // @supportURL https://bugs.example.com/ 4 | // @match *://*/* 5 | // ==/UserScript== 6 | -------------------------------------------------------------------------------- /test/integration/package-json/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { File, GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | @File(__dirname, './root-option.headers.txt') 5 | public static readonly rootOptionHeaders: string; 6 | 7 | @File(__dirname, './bugs.headers.txt') 8 | public static readonly bugsHeaders: string; 9 | } 10 | -------------------------------------------------------------------------------- /test/integration/package-json/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('package-json', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry.js': Fixtures.entryJs, 13 | }); 14 | }); 15 | 16 | it('should find package.json using root option', async () => { 17 | input.mkdirpSync('/some/deep/deep/dir'); 18 | input.writeFileSync( 19 | '/some/deep/deep/dir/package.json', 20 | JSON.stringify({ 21 | name: 'package-json-deep', 22 | version: '0.0.1', 23 | description: 'description of package-json-deep', 24 | author: 'author of package-json-deep', 25 | homepage: 'https://homepage.package-json-deep.com/', 26 | }), 27 | ); 28 | input.writeFileSync( 29 | '/package.json', 30 | JSON.stringify({ 31 | name: 'package-json', 32 | version: '0.0.0', 33 | description: 'description of package-json', 34 | author: 'author of package-json', 35 | homepage: 'https://homepage.package-json.com/', 36 | }), 37 | ); 38 | 39 | const output = await compile(input, { 40 | ...Fixtures.webpackConfig, 41 | plugins: [ 42 | new UserscriptPlugin({ 43 | root: '/some/deep/deep/dir/', 44 | }), 45 | ], 46 | }); 47 | 48 | expect(output.toJSON()).toEqual({ 49 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.rootOptionHeaders), 50 | '/dist/output.meta.js': Fixtures.rootOptionHeaders, 51 | }); 52 | }); 53 | 54 | describe('various types of "bugs" in package.json', () => { 55 | const bugsValues = [ 56 | 'https://bugs.example.com/', 57 | { url: 'https://bugs.example.com/' }, 58 | ]; 59 | 60 | for (const bugs of bugsValues) { 61 | it('should parse "bugs" into "supportURL"', async () => { 62 | input.writeFileSync( 63 | '/package.json', 64 | JSON.stringify({ name: 'package-json-bugs', bugs }), 65 | ); 66 | 67 | const output = await compile(input, { 68 | ...Fixtures.webpackConfig, 69 | plugins: [new UserscriptPlugin({})], 70 | }); 71 | 72 | expect(output.toJSON()).toEqual({ 73 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.bugsHeaders), 74 | '/dist/output.meta.js': Fixtures.bugsHeaders, 75 | }); 76 | }); 77 | } 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/integration/package-json/root-option.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name package-json-deep 3 | // @description description of package-json-deep 4 | // @version 0.0.1 5 | // @author author of package-json-deep 6 | // @homepage https://homepage.package-json-deep.com/ 7 | // @match *://*/* 8 | // ==/UserScript== 9 | -------------------------------------------------------------------------------- /test/integration/proxy-script/base-url-proxy-script.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @downloadURL http://example.com 7 | // @updateURL http://example.com 8 | // ==/UserScript== 9 | -------------------------------------------------------------------------------- /test/integration/proxy-script/base-url-proxy-script.proxy-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @require http://base.example.com/output.user.js 7 | // ==/UserScript== 8 | -------------------------------------------------------------------------------- /test/integration/proxy-script/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { File, GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | @File(__dirname, 'proxy-script.headers.txt') 5 | public static readonly proxyScriptHeaders: string; 6 | 7 | @File(__dirname, 'proxy-script.proxy-headers.txt') 8 | public static readonly proxyScriptProxyHeaders: string; 9 | 10 | @File(__dirname, 'base-url-proxy-script.proxy-headers.txt') 11 | public static readonly baseURLProxyScriptProxyHeaders: string; 12 | 13 | @File(__dirname, 'base-url-proxy-script.headers.txt') 14 | public static readonly baseURLProxyScriptHeaders: string; 15 | } 16 | -------------------------------------------------------------------------------- /test/integration/proxy-script/index.test.ts: -------------------------------------------------------------------------------- 1 | import { UserscriptPlugin } from 'webpack-userscript'; 2 | 3 | import { compile } from '../util'; 4 | import { Volume } from '../volume'; 5 | import { Fixtures } from './fixtures'; 6 | 7 | describe('proxy script', () => { 8 | let input: Volume; 9 | 10 | beforeEach(async () => { 11 | input = Volume.fromJSON({ 12 | '/entry.js': Fixtures.entryJs, 13 | '/package.json': Fixtures.packageJson, 14 | }); 15 | }); 16 | 17 | it('should generate proxy script at default location', async () => { 18 | const output = await compile(input, { 19 | ...Fixtures.webpackConfig, 20 | plugins: [ 21 | new UserscriptPlugin({ 22 | headers: { 23 | // these URLs should be ignored in proxy scripts 24 | updateURL: 'http://example.com', 25 | installURL: 'http://example.com', 26 | // require tag will be extended in the proxy script 27 | require: ['http://require.example.com'], 28 | }, 29 | proxyScript: {}, 30 | }), 31 | ], 32 | }); 33 | 34 | expect(output.toJSON()).toEqual({ 35 | '/dist/output.proxy.user.js': Fixtures.proxyScriptProxyHeaders, 36 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.proxyScriptHeaders), 37 | '/dist/output.meta.js': Fixtures.proxyScriptHeaders, 38 | }); 39 | }); 40 | 41 | it('should generate proxy script at specified location', async () => { 42 | const output = await compile(input, { 43 | ...Fixtures.webpackConfig, 44 | plugins: [ 45 | new UserscriptPlugin({ 46 | headers: { 47 | // these URLs should be ignored in proxy scripts 48 | updateURL: 'http://example.com', 49 | installURL: 'http://example.com', 50 | // require tag will be extended in the proxy script 51 | require: 'http://require.example.com', 52 | }, 53 | proxyScript: { 54 | filename: 'custom.proxy.user.js', 55 | }, 56 | }), 57 | ], 58 | }); 59 | 60 | expect(output.toJSON()).toEqual({ 61 | '/dist/custom.proxy.user.js': Fixtures.proxyScriptProxyHeaders, 62 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.proxyScriptHeaders), 63 | '/dist/output.meta.js': Fixtures.proxyScriptHeaders, 64 | }); 65 | }); 66 | 67 | it('should generate proxy script with custom baseURL of userjs', async () => { 68 | const output = await compile(input, { 69 | ...Fixtures.webpackConfig, 70 | plugins: [ 71 | new UserscriptPlugin({ 72 | headers: { 73 | // these URLs should be ignored in proxy scripts 74 | updateURL: 'http://example.com', 75 | downloadURL: 'http://example.com', 76 | }, 77 | proxyScript: { 78 | baseURL: 'http://base.example.com', 79 | }, 80 | }), 81 | ], 82 | }); 83 | 84 | expect(output.toJSON()).toEqual({ 85 | '/dist/output.proxy.user.js': Fixtures.baseURLProxyScriptProxyHeaders, 86 | '/dist/output.user.js': Fixtures.entryUserJs( 87 | Fixtures.baseURLProxyScriptHeaders, 88 | ), 89 | '/dist/output.meta.js': Fixtures.baseURLProxyScriptHeaders, 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/integration/proxy-script/proxy-script.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @installURL http://example.com 7 | // @require http://require.example.com 8 | // @updateURL http://example.com 9 | // ==/UserScript== 10 | -------------------------------------------------------------------------------- /test/integration/proxy-script/proxy-script.proxy-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @require http://require.example.com 7 | // @require http://localhost:8080/output.user.js 8 | // ==/UserScript== 9 | -------------------------------------------------------------------------------- /test/integration/resolve-base-urls/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { GlobalFixtures } from '../fixtures'; 2 | 3 | export class Fixtures extends GlobalFixtures { 4 | public static readonly downloadURLWithUserjs = 5 | 'http://download.example.com/output.user.js'; 6 | 7 | public static readonly downloadURLWithMetajs = 8 | 'http://download.example.com/output.meta.js'; 9 | 10 | public static readonly updateURLWithMetajs = 11 | 'http://update.example.com/output.meta.js'; 12 | 13 | public static readonly updateURLWithUserjs = 14 | 'http://update.example.com/output.user.js'; 15 | } 16 | -------------------------------------------------------------------------------- /test/integration/resolve-base-urls/index.test.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | 3 | import { UserscriptPlugin } from 'webpack-userscript'; 4 | 5 | import { compile, findTags } from '../util'; 6 | import { Volume } from '../volume'; 7 | import { Fixtures } from './fixtures'; 8 | 9 | describe('resolve base urls', () => { 10 | let input: Volume; 11 | 12 | const findDownloadURL = findTags.bind( 13 | undefined, 14 | 'downloadURL', 15 | Fixtures.downloadURLWithUserjs, 16 | ); 17 | 18 | beforeEach(async () => { 19 | input = Volume.fromJSON({ 20 | '/entry.js': Fixtures.entryJs, 21 | '/package.json': Fixtures.packageJson, 22 | }); 23 | }); 24 | 25 | it('should resolve downloadURL and updateURL', async () => { 26 | const findUpdateURLByMetajs = findTags.bind( 27 | undefined, 28 | 'updateURL', 29 | Fixtures.updateURLWithMetajs, 30 | ); 31 | 32 | const output = await compile(input, { 33 | ...Fixtures.webpackConfig, 34 | plugins: [ 35 | new UserscriptPlugin({ 36 | downloadBaseURL: new URL('http://download.example.com'), 37 | updateBaseURL: 'http://update.example.com', 38 | }), 39 | ], 40 | }); 41 | 42 | const userJs = output 43 | .readFileSync('/dist/output.user.js') 44 | .toString('utf-8'); 45 | const metaJs = output 46 | .readFileSync('/dist/output.meta.js') 47 | .toString('utf-8'); 48 | 49 | expect(findDownloadURL(userJs)).toHaveLength(1); 50 | expect(findDownloadURL(metaJs)).toHaveLength(1); 51 | 52 | expect(findUpdateURLByMetajs(userJs)).toHaveLength(1); 53 | expect(findUpdateURLByMetajs(metaJs)).toHaveLength(1); 54 | }); 55 | 56 | it('should resolve updateURL with updateBaseURL and userjs', async () => { 57 | const findUpdateURLByUserjs = findTags.bind( 58 | undefined, 59 | 'updateURL', 60 | Fixtures.updateURLWithUserjs, 61 | ); 62 | 63 | const output = await compile(input, { 64 | ...Fixtures.webpackConfig, 65 | plugins: [ 66 | new UserscriptPlugin({ 67 | downloadBaseURL: new URL('http://download.example.com'), 68 | updateBaseURL: 'http://update.example.com', 69 | metajs: false, 70 | }), 71 | ], 72 | }); 73 | 74 | const userJs = output 75 | .readFileSync('/dist/output.user.js') 76 | .toString('utf-8'); 77 | 78 | expect(findDownloadURL(userJs)).toHaveLength(1); 79 | expect(findUpdateURLByUserjs(userJs)).toHaveLength(1); 80 | }); 81 | 82 | it('should resolve updateURL by downloadBaseURL and userjs', async () => { 83 | const findUpdateURLByDownloadURL = findTags.bind( 84 | undefined, 85 | 'updateURL', 86 | Fixtures.downloadURLWithUserjs, 87 | ); 88 | 89 | const output = await compile(input, { 90 | ...Fixtures.webpackConfig, 91 | plugins: [ 92 | new UserscriptPlugin({ 93 | downloadBaseURL: new URL('http://download.example.com'), 94 | metajs: false, 95 | }), 96 | ], 97 | }); 98 | 99 | const userJs = output 100 | .readFileSync('/dist/output.user.js') 101 | .toString('utf-8'); 102 | 103 | expect(findDownloadURL(userJs)).toHaveLength(1); 104 | expect(findUpdateURLByDownloadURL(userJs)).toHaveLength(1); 105 | }); 106 | 107 | it('should resolve updateURL by downloadBaseURL and metajs', async () => { 108 | const findUpdateURLByDownloadURL = findTags.bind( 109 | undefined, 110 | 'updateURL', 111 | Fixtures.downloadURLWithMetajs, 112 | ); 113 | 114 | const output = await compile(input, { 115 | ...Fixtures.webpackConfig, 116 | plugins: [ 117 | new UserscriptPlugin({ 118 | downloadBaseURL: new URL('http://download.example.com'), 119 | metajs: true, 120 | }), 121 | ], 122 | }); 123 | 124 | const userJs = output 125 | .readFileSync('/dist/output.user.js') 126 | .toString('utf-8'); 127 | 128 | expect(findDownloadURL(userJs)).toHaveLength(1); 129 | expect(findUpdateURLByDownloadURL(userJs)).toHaveLength(1); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/integration/ssri/algorithms-ssri-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68= 7 | // @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40= 8 | // ==/UserScript== 9 | -------------------------------------------------------------------------------- /test/integration/ssri/algorithms-ssri-lock.json.txt: -------------------------------------------------------------------------------- 1 | {"http://localhost:${PORT}/travis-webpack-userscript.svg":"sha256-J8iln9kXwFC+zyHUKYPQ3Bl6cCo+LTlXJA/+x/7DR40=","http://localhost:${PORT}/jquery-3.4.1.min.js":"sha256-TCTf0oeErSvvs9r6rGvx7U581YzOcT2aCyKNQm6BK68="} -------------------------------------------------------------------------------- /test/integration/ssri/filters-ssri-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug== 7 | // @require http://example.com/example.txt 8 | // @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg 9 | // ==/UserScript== 10 | -------------------------------------------------------------------------------- /test/integration/ssri/filters-ssri-lock.json.txt: -------------------------------------------------------------------------------- 1 | {"http://localhost:${PORT}/jquery-3.4.1.min.js":"sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug=="} -------------------------------------------------------------------------------- /test/integration/ssri/fixtures.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import { Memoize } from 'typescript-memoize'; 4 | 5 | import { File, GlobalFixtures } from '../fixtures'; 6 | import { template } from '../util'; 7 | 8 | export class Fixtures extends GlobalFixtures { 9 | @File(path.join(__dirname, 'ssri-lock.json.txt')) 10 | private static readonly _ssriLockJson: string; 11 | 12 | public static get ssriLockJson(): (data: any) => string { 13 | return template(this._ssriLockJson); 14 | } 15 | 16 | @File(path.join(__dirname, 'ssri-headers.txt')) 17 | private static readonly _ssriHeaders: string; 18 | 19 | @Memoize() 20 | public static get ssriHeaders(): (data: any) => string { 21 | return template(this._ssriHeaders); 22 | } 23 | 24 | @File(path.join(__dirname, 'filters-ssri-headers.txt')) 25 | private static readonly _filtersSSRIHeaders: string; 26 | 27 | @Memoize() 28 | public static get filtersSSRIHeaders(): (data: any) => string { 29 | return template(this._filtersSSRIHeaders); 30 | } 31 | 32 | @File(path.join(__dirname, 'filters-ssri-lock.json.txt')) 33 | private static readonly _filtersSSRILockJson: string; 34 | 35 | @Memoize() 36 | public static get filtersSSRILockJson(): (data: any) => string { 37 | return template(this._filtersSSRILockJson); 38 | } 39 | 40 | @File(path.join(__dirname, 'algorithms-ssri-headers.txt')) 41 | private static readonly _algorithmsSSRIHeaders: string; 42 | 43 | @Memoize() 44 | public static get algorithmsSSRIHeaders(): (data: any) => string { 45 | return template(this._algorithmsSSRIHeaders); 46 | } 47 | 48 | @File(path.join(__dirname, 'algorithms-ssri-lock.json.txt')) 49 | private static readonly _algorithmsSSRILockJson: string; 50 | 51 | @Memoize() 52 | public static get algorithmsSSRILockJson(): (data: any) => string { 53 | return template(this._algorithmsSSRILockJson); 54 | } 55 | 56 | @File(__dirname, 'multi-algo-ssri-lock.json.txt') 57 | private static readonly _multiAlgorithmsSSRILockJson: string; 58 | 59 | @Memoize() 60 | public static get multiAlgorithmsSSRILockJson(): (data: any) => string { 61 | return template(this._multiAlgorithmsSSRILockJson); 62 | } 63 | 64 | @File(__dirname, 'unsupported-protocols.headers.txt') 65 | public static readonly _unsupportedProtocolsHeaders: string; 66 | 67 | @Memoize() 68 | public static get unsupportedProtocolsHeaders(): (data: any) => string { 69 | return template(this._unsupportedProtocolsHeaders); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/integration/ssri/index.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import fetch from 'node-fetch'; 4 | import { UserscriptPlugin } from 'webpack-userscript'; 5 | 6 | import { compile, readJSON, servceStatic, ServeStatic } from '../util'; 7 | import { createFsFromVolume, Volume } from '../volume'; 8 | import { Fixtures } from './fixtures'; 9 | 10 | jest.mock('node-fetch', () => 11 | jest.fn(jest.requireActual('node-fetch') as typeof fetch), 12 | ); 13 | 14 | describe('ssri', () => { 15 | let input: Volume; 16 | let server: ServeStatic; 17 | let tplData: { PORT: string }; 18 | 19 | beforeAll(async () => { 20 | server = await servceStatic(path.join(__dirname, 'static')); 21 | tplData = { 22 | PORT: String(server.port), 23 | }; 24 | }); 25 | 26 | afterAll(async () => { 27 | await server.close(); 28 | }); 29 | 30 | beforeEach(async () => { 31 | input = Volume.fromJSON({ 32 | '/entry.js': Fixtures.entryJs, 33 | '/package.json': Fixtures.packageJson, 34 | }); 35 | }); 36 | 37 | it('should generate SSRIs and ssri-lock.json under the context', async () => { 38 | const output = await compile(input, { 39 | ...Fixtures.webpackConfig, 40 | context: '/home', 41 | plugins: [ 42 | new UserscriptPlugin({ 43 | headers: { 44 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 45 | resource: { 46 | // eslint-disable-next-line max-len 47 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 48 | }, 49 | }, 50 | ssri: {}, 51 | }), 52 | ], 53 | }); 54 | 55 | expect(output.toJSON()).toEqual( 56 | expect.objectContaining({ 57 | '/dist/output.user.js': Fixtures.entryUserJs( 58 | Fixtures.ssriHeaders(tplData), 59 | ), 60 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 61 | }), 62 | ); 63 | 64 | expect(readJSON(output, '/home/ssri-lock.json')).toEqual( 65 | JSON.parse(Fixtures.ssriLockJson(tplData)), 66 | ); 67 | }); 68 | 69 | it('should generate SSRIs and ssri-lock.json under the root', async () => { 70 | const output = await compile(input, { 71 | ...Fixtures.webpackConfig, 72 | context: '/home', 73 | plugins: [ 74 | new UserscriptPlugin({ 75 | headers: { 76 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 77 | resource: { 78 | // eslint-disable-next-line max-len 79 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 80 | }, 81 | }, 82 | root: '/data', 83 | ssri: {}, 84 | }), 85 | ], 86 | }); 87 | 88 | expect(output.toJSON()).toEqual( 89 | expect.objectContaining({ 90 | '/dist/output.user.js': Fixtures.entryUserJs( 91 | Fixtures.ssriHeaders(tplData), 92 | ), 93 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 94 | }), 95 | ); 96 | 97 | expect(readJSON(output, '/data/ssri-lock.json')).toEqual( 98 | JSON.parse(Fixtures.ssriLockJson(tplData)), 99 | ); 100 | }); 101 | 102 | it('should generate ssri-lock in custom lockfile', async () => { 103 | const output = await compile(input, { 104 | ...Fixtures.webpackConfig, 105 | context: '/home', 106 | plugins: [ 107 | new UserscriptPlugin({ 108 | headers: { 109 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 110 | resource: { 111 | // eslint-disable-next-line max-len 112 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 113 | }, 114 | }, 115 | ssri: { 116 | lock: '/some/deep/dir/custom-lock.json', 117 | }, 118 | }), 119 | ], 120 | }); 121 | 122 | expect(output.toJSON()).toEqual( 123 | expect.objectContaining({ 124 | '/dist/output.user.js': Fixtures.entryUserJs( 125 | Fixtures.ssriHeaders(tplData), 126 | ), 127 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 128 | }), 129 | ); 130 | 131 | expect(readJSON(output, '/some/deep/dir/custom-lock.json')).toEqual( 132 | JSON.parse(Fixtures.ssriLockJson(tplData)), 133 | ); 134 | }); 135 | 136 | it('should apply url filters to determine SSRI target urls', async () => { 137 | const output = await compile(input, { 138 | ...Fixtures.webpackConfig, 139 | plugins: [ 140 | new UserscriptPlugin({ 141 | headers: { 142 | require: [ 143 | `http://localhost:${server.port}/jquery-3.4.1.min.js`, 144 | `http://example.com/example.txt`, 145 | ], 146 | resource: { 147 | // eslint-disable-next-line max-len 148 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 149 | }, 150 | }, 151 | ssri: { 152 | include: (_, url): boolean => url.hostname.includes('localhost'), 153 | exclude: (tag, url): boolean => 154 | tag === 'resource' || !url.pathname.endsWith('.js'), 155 | }, 156 | }), 157 | ], 158 | }); 159 | 160 | expect(output.toJSON()).toEqual( 161 | expect.objectContaining({ 162 | '/dist/output.user.js': Fixtures.entryUserJs( 163 | Fixtures.filtersSSRIHeaders(tplData), 164 | ), 165 | '/dist/output.meta.js': Fixtures.filtersSSRIHeaders(tplData), 166 | }), 167 | ); 168 | 169 | expect(readJSON(output, '/ssri-lock.json')).toEqual( 170 | JSON.parse(Fixtures.filtersSSRILockJson(tplData)), 171 | ); 172 | }); 173 | 174 | it('should not generate SSRIs for unsupported protocols', async () => { 175 | const output = await compile(input, { 176 | ...Fixtures.webpackConfig, 177 | context: '/home', 178 | plugins: [ 179 | new UserscriptPlugin({ 180 | headers: { 181 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 182 | resource: { 183 | // eslint-disable-next-line max-len 184 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 185 | 'unsupported-url': 'ftp://example.com', 186 | }, 187 | }, 188 | root: '/data', 189 | ssri: {}, 190 | }), 191 | ], 192 | }); 193 | 194 | expect(output.toJSON()).toEqual( 195 | expect.objectContaining({ 196 | '/dist/output.user.js': Fixtures.entryUserJs( 197 | Fixtures.unsupportedProtocolsHeaders(tplData), 198 | ), 199 | '/dist/output.meta.js': Fixtures.unsupportedProtocolsHeaders(tplData), 200 | }), 201 | ); 202 | 203 | expect(readJSON(output, '/data/ssri-lock.json')).toEqual( 204 | JSON.parse(Fixtures.ssriLockJson(tplData)), 205 | ); 206 | }); 207 | 208 | it('should generate SSRIs based on provided algorithms', async () => { 209 | const output = await compile(input, { 210 | ...Fixtures.webpackConfig, 211 | plugins: [ 212 | new UserscriptPlugin({ 213 | headers: { 214 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 215 | resource: { 216 | // eslint-disable-next-line max-len 217 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 218 | }, 219 | }, 220 | ssri: { 221 | algorithms: ['sha256'], 222 | }, 223 | }), 224 | ], 225 | }); 226 | 227 | expect(output.toJSON()).toEqual( 228 | expect.objectContaining({ 229 | '/dist/output.user.js': Fixtures.entryUserJs( 230 | Fixtures.algorithmsSSRIHeaders(tplData), 231 | ), 232 | '/dist/output.meta.js': Fixtures.algorithmsSSRIHeaders(tplData), 233 | }), 234 | ); 235 | 236 | expect(readJSON(output, '/ssri-lock.json')).toEqual( 237 | JSON.parse(Fixtures.algorithmsSSRILockJson(tplData)), 238 | ); 239 | }); 240 | 241 | it('should generate SSRIs without ssri-lock.json', async () => { 242 | const output = await compile(input, { 243 | ...Fixtures.webpackConfig, 244 | plugins: [ 245 | new UserscriptPlugin({ 246 | headers: { 247 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 248 | resource: { 249 | // eslint-disable-next-line max-len 250 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 251 | }, 252 | }, 253 | ssri: { 254 | lock: false, 255 | }, 256 | }), 257 | ], 258 | }); 259 | 260 | expect(output.toJSON()).toEqual({ 261 | '/dist/output.user.js': Fixtures.entryUserJs( 262 | Fixtures.ssriHeaders(tplData), 263 | ), 264 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 265 | }); 266 | }); 267 | 268 | it('should generate SSRIs with existing ssri-lock.json', async () => { 269 | input.mkdirpSync('/data'); 270 | input.writeFileSync('/data/ssri-lock.json', Fixtures.ssriLockJson(tplData)); 271 | 272 | const intermediateFileSystem = createFsFromVolume(new Volume()); 273 | const writeFileSpy = jest.spyOn(intermediateFileSystem, 'writeFile'); 274 | 275 | const output = await compile( 276 | input, 277 | { 278 | ...Fixtures.webpackConfig, 279 | context: '/data', 280 | plugins: [ 281 | new UserscriptPlugin({ 282 | headers: { 283 | require: `http://localhost:${server.port}/jquery-3.4.1.min.js`, 284 | resource: { 285 | // eslint-disable-next-line max-len 286 | 'legacy-badge': `http://localhost:${server.port}/travis-webpack-userscript.svg`, 287 | }, 288 | }, 289 | ssri: {}, 290 | }), 291 | ], 292 | }, 293 | { 294 | intermediateFileSystem, 295 | }, 296 | ); 297 | 298 | expect(fetch).not.toBeCalled(); 299 | expect(writeFileSpy).not.toBeCalled(); 300 | 301 | expect(output.toJSON()).toEqual({ 302 | '/dist/output.user.js': Fixtures.entryUserJs( 303 | Fixtures.ssriHeaders(tplData), 304 | ), 305 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 306 | // there is no ssri-lock.json in output FS 307 | // since ssri-lock remains unchanged (no write happened) 308 | }); 309 | 310 | writeFileSpy.mockRestore(); 311 | }); 312 | 313 | it('should generate SSRIs with existing SSRIs in headers', async () => { 314 | const output = await compile(input, { 315 | ...Fixtures.webpackConfig, 316 | context: '/data', 317 | plugins: [ 318 | new UserscriptPlugin({ 319 | headers: { 320 | require: 321 | `http://localhost:${server.port}/jquery-3.4.1.min.js` + 322 | `#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` + 323 | `T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`, 324 | resource: { 325 | 'legacy-badge': 326 | `http://localhost:${server.port}` + 327 | `/travis-webpack-userscript.svg` + 328 | `#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` + 329 | `Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`, 330 | }, 331 | }, 332 | ssri: {}, 333 | }), 334 | ], 335 | }); 336 | 337 | expect(fetch).not.toBeCalled(); 338 | 339 | expect(output.toJSON()).toEqual( 340 | expect.objectContaining({ 341 | '/dist/output.user.js': Fixtures.entryUserJs( 342 | Fixtures.ssriHeaders(tplData), 343 | ), 344 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 345 | }), 346 | ); 347 | 348 | expect(readJSON(output, '/data/ssri-lock.json')).toEqual( 349 | JSON.parse(Fixtures.ssriLockJson(tplData)), 350 | ); 351 | }); 352 | 353 | it( 354 | 'should throw error if SSRIs mismatch between those from headers and ' + 355 | 'those from ssri-lock.json', 356 | async () => { 357 | input.mkdirpSync('/data'); 358 | // correct SSRIs are in ssri-lock.json 359 | input.writeFileSync( 360 | '/data/ssri-lock.json', 361 | Fixtures.ssriLockJson(tplData), 362 | ); 363 | 364 | const promise = compile(input, { 365 | ...Fixtures.webpackConfig, 366 | context: '/data', 367 | plugins: [ 368 | new UserscriptPlugin({ 369 | // I switch SSRIs of jquery-3.4.1.min.js 370 | // and travis-webpack-userscript.svg 371 | // to simulate a mismatch 372 | headers: { 373 | require: 374 | `http://localhost:${server.port}/jquery-3.4.1.min.js` + 375 | `#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` + 376 | `Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`, 377 | resource: { 378 | 'legacy-badge': 379 | `http://localhost:${server.port}` + 380 | `/travis-webpack-userscript.svg` + 381 | `#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` + 382 | `T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`, 383 | }, 384 | }, 385 | ssri: {}, 386 | }), 387 | ], 388 | }); 389 | 390 | expect(fetch).not.toBeCalled(); 391 | await expect(promise).toReject(); 392 | }, 393 | ); 394 | 395 | it('should compile if no urls are found', async () => { 396 | const output = await compile(input, { 397 | ...Fixtures.webpackConfig, 398 | plugins: [ 399 | new UserscriptPlugin({ 400 | ssri: {}, 401 | }), 402 | ], 403 | }); 404 | 405 | expect(output.toJSON()).toEqual({ 406 | '/dist/output.user.js': Fixtures.entryUserJs(Fixtures.headers), 407 | '/dist/output.meta.js': Fixtures.headers, 408 | }); 409 | }); 410 | 411 | it('should generate SSRIs against missing algorithms', async () => { 412 | const output = await compile(input, { 413 | ...Fixtures.webpackConfig, 414 | context: '/data', 415 | plugins: [ 416 | new UserscriptPlugin({ 417 | headers: { 418 | require: 419 | `http://localhost:${server.port}/jquery-3.4.1.min.js` + 420 | `#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+ene` + 421 | `T6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug==`, 422 | resource: { 423 | 'legacy-badge': 424 | `http://localhost:${server.port}` + 425 | `/travis-webpack-userscript.svg` + 426 | `#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34` + 427 | `Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ==`, 428 | }, 429 | }, 430 | ssri: { 431 | algorithms: ['sha256'], 432 | }, 433 | }), 434 | ], 435 | }); 436 | 437 | expect(output.toJSON()).toEqual( 438 | expect.objectContaining({ 439 | '/dist/output.user.js': Fixtures.entryUserJs( 440 | Fixtures.ssriHeaders(tplData), 441 | ), 442 | '/dist/output.meta.js': Fixtures.ssriHeaders(tplData), 443 | }), 444 | ); 445 | 446 | expect(readJSON(output, '/data/ssri-lock.json')).toEqual( 447 | JSON.parse(Fixtures.multiAlgorithmsSSRILockJson(tplData)), 448 | ); 449 | }); 450 | 451 | it('should throw if fetching sources falied', () => { 452 | return expect( 453 | compile(input, { 454 | ...Fixtures.webpackConfig, 455 | plugins: [ 456 | new UserscriptPlugin({ 457 | headers: { 458 | require: `http://localhost:${server.port}/not-exist.js`, 459 | }, 460 | ssri: {}, 461 | }), 462 | ], 463 | }), 464 | ).rejects.toThrow(/404 Not Found/); 465 | }); 466 | }); 467 | -------------------------------------------------------------------------------- /test/integration/ssri/multi-algo-ssri-lock.json.txt: -------------------------------------------------------------------------------- 1 | {"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=="} -------------------------------------------------------------------------------- /test/integration/ssri/ssri-headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug== 7 | // @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ== 8 | // ==/UserScript== 9 | -------------------------------------------------------------------------------- /test/integration/ssri/ssri-lock.json.txt: -------------------------------------------------------------------------------- 1 | {"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=="} -------------------------------------------------------------------------------- /test/integration/ssri/static/.eslintignore: -------------------------------------------------------------------------------- 1 | ./**/* -------------------------------------------------------------------------------- /test/integration/ssri/static/travis-webpack-userscript.svg: -------------------------------------------------------------------------------- 1 | 2 | buildbuildpassingpassing -------------------------------------------------------------------------------- /test/integration/ssri/unsupported-protocols.headers.txt: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name userscript 3 | // @description this is a fantastic userscript 4 | // @version 0.0.0 5 | // @match *://*/* 6 | // @require http://localhost:${PORT}/jquery-3.4.1.min.js#sha512-udvAjJhK48f9RIIuwumiLLjPfaVfo5ddu9w/GP1+eneT6Nk2BIJldOPdak+YLXr0+Wwa9eENhHuDlKNKgsOYug== 7 | // @resource legacy-badge http://localhost:${PORT}/travis-webpack-userscript.svg#sha512-/xTO4jHEEl9gsQ2JvSjA9iMdzyiqapzDMfgtbLV34Qiic7xUbs+urnF8cdAi2ApfQlgYTb5ZQTkTQaZEHCApnQ== 8 | // @resource unsupported-url ftp://example.com 9 | // ==/UserScript== 10 | -------------------------------------------------------------------------------- /test/integration/util.ts: -------------------------------------------------------------------------------- 1 | import { mkdtemp, rm, writeFile } from 'node:fs/promises'; 2 | import { AddressInfo } from 'node:net'; 3 | import { tmpdir } from 'node:os'; 4 | import path from 'node:path'; 5 | import { promisify } from 'node:util'; 6 | 7 | import express, { static as expressStatic } from 'express'; 8 | import { Compiler, Configuration, webpack } from 'webpack'; 9 | 10 | import { createFsFromVolume, Volume } from './volume'; 11 | 12 | export const GLOBAL_FIXTURES_DIR = path.join(__dirname, 'fixtures'); 13 | 14 | export interface CompilerFileSystems { 15 | intermediateFileSystem?: Volume; 16 | } 17 | 18 | type IntermediateFileSystem = Compiler['intermediateFileSystem']; 19 | 20 | export async function compile( 21 | input: Volume, 22 | config: Configuration, 23 | { intermediateFileSystem }: CompilerFileSystems = {}, 24 | ): Promise { 25 | const output = new Volume(); 26 | const compiler = webpack(config); 27 | 28 | compiler.inputFileSystem = createFsFromVolume(input); 29 | compiler.outputFileSystem = createFsFromVolume(output); 30 | compiler.intermediateFileSystem = (intermediateFileSystem ?? 31 | compiler.outputFileSystem) as IntermediateFileSystem; 32 | 33 | const stats = await promisify(compiler.run.bind(compiler))(); 34 | await promisify(compiler.close.bind(compiler))(); 35 | 36 | if (stats?.hasErrors() || stats?.hasWarnings()) { 37 | const details = stats.toJson(); 38 | 39 | if (details.errorsCount) { 40 | console.error(details.errors); 41 | } 42 | if (details.warningsCount) { 43 | console.error(details.warnings); 44 | } 45 | 46 | throw new Error('invalid fixtures'); 47 | } 48 | 49 | return output; 50 | } 51 | 52 | export interface WatchStep { 53 | cwd: string; 54 | output: Volume; 55 | writeFile: ( 56 | file: string, 57 | body: string, 58 | encode?: BufferEncoding, 59 | ) => Promise; 60 | } 61 | 62 | export async function watchCompile( 63 | input: Volume, 64 | config: Configuration, 65 | handle: (ctx: WatchStep) => Promise, 66 | ): Promise { 67 | return new Promise(async (resolve, reject) => { 68 | const watchDir = await mkdtemp( 69 | path.join(tmpdir(), 'webpack-userscript_test_'), 70 | ); 71 | 72 | for (const [file, body] of Object.entries(input.toJSON())) { 73 | if (body === null) { 74 | continue; 75 | } 76 | 77 | await writeFile(path.join(watchDir, file), body, 'utf-8'); 78 | } 79 | 80 | const output = new Volume(); 81 | const compiler = webpack({ 82 | ...config, 83 | context: 84 | typeof config.context === 'string' 85 | ? path.join(watchDir, config.context) 86 | : undefined, 87 | }); 88 | 89 | compiler.outputFileSystem = createFsFromVolume(output); 90 | 91 | const watching = compiler.watch({}, async (err, stats) => { 92 | if (err) { 93 | await close(); 94 | reject(err); 95 | 96 | return; 97 | } 98 | 99 | if (stats?.hasErrors() || stats?.hasWarnings()) { 100 | const details = stats.toJson(); 101 | 102 | if (details.errorsCount) { 103 | console.error(details.errors); 104 | } 105 | if (details.warningsCount) { 106 | console.error(details.warnings); 107 | } 108 | 109 | await close(); 110 | reject(new Error('invalid fixtures')); 111 | 112 | return; 113 | } 114 | 115 | try { 116 | const conti = await handle({ 117 | cwd: watchDir, 118 | output, 119 | writeFile: writeFileInWatchDir, 120 | }); 121 | 122 | if (!conti) { 123 | await close(); 124 | resolve(); 125 | } 126 | } catch (err) { 127 | await close(); 128 | reject(err); 129 | } 130 | }); 131 | 132 | const closeWatching = promisify(watching.close.bind(watching)); 133 | const closeCompiler = promisify(compiler.close.bind(compiler)); 134 | 135 | const close = async (): Promise => { 136 | await closeWatching(); 137 | await closeCompiler(); 138 | await rm(watchDir, { recursive: true, force: true }); 139 | }; 140 | 141 | const writeFileInWatchDir = ( 142 | file: string, 143 | body: string, 144 | encode?: BufferEncoding, 145 | ): Promise => writeFile(path.join(watchDir, file), body, encode); 146 | }); 147 | } 148 | 149 | export interface ServeStatic { 150 | port: number; 151 | close: () => Promise; 152 | } 153 | 154 | export async function servceStatic(root: string): Promise { 155 | return new Promise((resolve, reject) => { 156 | const app = express(); 157 | app.use(expressStatic(root)); 158 | const server = app 159 | .listen(() => { 160 | const { port } = server.address() as AddressInfo; 161 | resolve({ port, close: promisify(server.close.bind(server)) }); 162 | }) 163 | .on('error', (err) => { 164 | reject(err); 165 | }); 166 | }); 167 | } 168 | 169 | export function escapeRegex(str: string): string { 170 | return str.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); 171 | } 172 | 173 | export function findTags( 174 | tag: string, 175 | value: string, 176 | content: string, 177 | ): string[] { 178 | return ( 179 | content.match( 180 | new RegExp(`// @${escapeRegex(tag)} ${escapeRegex(value)}\n`, 'g'), 181 | ) ?? [] 182 | ); 183 | } 184 | 185 | export function template>( 186 | tpl: string, 187 | ): (data: T) => string { 188 | return function (data: T): string { 189 | return tpl.replace( 190 | /\$\{([^}]+)\}/g, 191 | (matched, matchedKey) => data[matchedKey] ?? matched, 192 | ); 193 | }; 194 | } 195 | 196 | export function readJSON(vol: Volume, file: string): unknown { 197 | return JSON.parse(vol.readFileSync(file).toString('utf-8')); 198 | } 199 | -------------------------------------------------------------------------------- /test/integration/volume.ts: -------------------------------------------------------------------------------- 1 | import { createFsFromVolume as memfsCreateFsFromVolume } from 'memfs'; 2 | import { Volume } from 'memfs/lib/volume'; 3 | import { InputFileSystem, OutputFileSystem } from 'webpack'; 4 | 5 | export const createFsFromVolume = memfsCreateFsFromVolume as unknown as ( 6 | ...args: Parameters 7 | ) => Volume & InputFileSystem & OutputFileSystem; 8 | 9 | export { Volume }; 10 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["test", "jest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es6", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "strictFunctionTypes": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "importHelpers": true, 17 | "skipLibCheck": true, 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true, 20 | "experimentalDecorators": true, 21 | "sourceMap": true, 22 | "outDir": "./dist/", 23 | "types": ["node", "jest"], 24 | "lib": ["ESNext"], 25 | "emitDecoratorMetadata": true, 26 | "paths": { 27 | "webpack-userscript": ["lib"], 28 | "webpack-userscript/*": ["lib/*"], 29 | "class-transformer/cjs/storage": ["node_modules/class-transformer/types/storage"] 30 | } 31 | }, 32 | "include": ["**/*.ts"] 33 | } 34 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["lib/index.ts"], 3 | "out": "docs" 4 | } 5 | --------------------------------------------------------------------------------