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