├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages ├── rollup-plugin-webbundle │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── test │ │ ├── fixtures │ │ ├── index.js │ │ └── static │ │ │ └── index.html │ │ ├── snapshots │ │ ├── test.js.md │ │ └── test.js.snap │ │ └── test.js ├── shared │ ├── iwa-headers.ts │ ├── package.json │ ├── test │ │ └── test.js │ ├── tsconfig.json │ ├── types.ts │ ├── utils.ts │ └── wbn-types.ts └── webbundle-webpack-plugin │ ├── README.md │ ├── index.cjs │ ├── package.json │ ├── src │ └── index.ts │ └── test │ ├── fixtures │ ├── app.js │ └── static │ │ └── index.html │ └── test.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = { 18 | env: { 19 | es2021: true, 20 | node: true, 21 | }, 22 | plugins: ['@typescript-eslint', 'header'], 23 | extends: [ 24 | 'eslint:recommended', 25 | 'plugin:@typescript-eslint/recommended', 26 | 'prettier', 27 | ], 28 | overrides: [], 29 | parser: '@typescript-eslint/parser', 30 | parserOptions: { 31 | ecmaVersion: 'latest', 32 | sourceType: 'module', 33 | }, 34 | rules: { 35 | 'header/header': [ 36 | 2, 37 | 'block', 38 | [ 39 | // The exclamation mark marks this header as a license header, which 40 | // some tools, like bundlers, treat differently to normal headers. 41 | '!', 42 | { 43 | pattern: ' \\* Copyright \\d{4} Google LLC', 44 | template: ` * Copyright ${new Date().getFullYear()} Google LLC`, 45 | }, 46 | ' *', 47 | ' * Licensed under the Apache License, Version 2.0 (the "License");', 48 | ' * you may not use this file except in compliance with the License.', 49 | ' * You may obtain a copy of the License at', 50 | ' *', 51 | ' * http://www.apache.org/licenses/LICENSE-2.0', 52 | ' *', 53 | ' * Unless required by applicable law or agreed to in writing, software', 54 | ' * distributed under the License is distributed on an "AS IS" BASIS,', 55 | ' * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.', 56 | ' * See the License for the specific language governing permissions and', 57 | ' * limitations under the License.', 58 | ' ', 59 | ], 60 | ], 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | defaults: 9 | run: 10 | working-directory: ./ 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | test-case: [ 16 | # Lowest supported versions are 1.21.0 for Rollup and 4.0.1 for Webpack. 17 | { node: 14.x, rollup: 1.21.0, webpack: 4.0.1 }, 18 | { node: 14.x, rollup: ^1.21.0, webpack: ^4.0.1 }, 19 | { node: 14.x, rollup: ^2.0.0, webpack: 5.0.x }, 20 | { node: 14.x, rollup: ^3.0.0, webpack: ^5.0.0 }, 21 | 22 | { node: 16.x, rollup: 1.21.x, webpack: 4.1.x }, 23 | { node: 16.x, rollup: ^1.21.0, webpack: ^4.0.1 }, 24 | { node: 16.x, rollup: ^2.0.0, webpack: 5.0.x }, 25 | { node: 16.x, rollup: ^3.0.0, webpack: ^5.0.0 }, 26 | 27 | # Node 18 only works with Webpack version ^5.61.x. 28 | { node: 18.x, rollup: 1.21.x, webpack: 5.61.x }, 29 | { node: 18.x, rollup: ^1.21.0, webpack: ^5.0.0 }, 30 | { node: 18.x, rollup: ^2.0.0, webpack: 5.61.x }, 31 | { node: 18.x, rollup: ^3.0.0, webpack: ^5.0.0 }, 32 | ] 33 | 34 | steps: 35 | - uses: actions/checkout@v3 36 | 37 | - name: Use Node.js ${{ matrix.test-case.node }} 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: ${{ matrix.test-case.node }} 41 | cache: 'npm' 42 | 43 | # Underlying wbn-sign requires to use npm v8+ 44 | - run: npm install -g npm@8 45 | 46 | - name: Install deps 47 | run: npm ci 48 | 49 | - name: Uninstall old versions of Webpack 50 | run: npm uninstall -D @types/webpack webpack 51 | working-directory: ./packages/webbundle-webpack-plugin 52 | 53 | - name: Uninstall old versions of Rollup 54 | run: npm uninstall -D rollup 55 | working-directory: ./packages/rollup-plugin-webbundle 56 | 57 | - name: Install Rollup v${{ matrix.test-case.rollup }} 58 | run: npm install --save-dev rollup@${{ matrix.test-case.rollup }} 59 | working-directory: ./packages/rollup-plugin-webbundle 60 | 61 | - name: Install Webpack v${{ matrix.test-case.webpack }} 62 | run: 63 | npm install --save-dev webpack@${{ matrix.test-case.webpack }} 64 | @types/webpack 65 | working-directory: ./packages/webbundle-webpack-plugin 66 | 67 | - run: npm run build 68 | - run: npm run test 69 | 70 | lint: 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: actions/checkout@v3 74 | - name: Use Node.js 18.x 75 | uses: actions/setup-node@v3 76 | with: 77 | node-version: 18.x 78 | cache: 'npm' 79 | - run: npm ci 80 | - run: npm run lint 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.tgz 3 | lib/ 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | .github 3 | test/ 4 | src/ 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | All Google open source projects are covered by our 4 | [community guidelines](https://opensource.google/conduct/) which define the kind 5 | of respectful behavior we expect of all participants. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows 28 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 29 | 30 | ## Auto-formatting code 31 | 32 | The Github Actions workflow enforces linting code with Prettier according to the 33 | Prettier configs specified in the package.json. 34 | 35 | To lint your code locally before committing, one can run `npm run lint`. 36 | 37 | To enable running Prettier on save with VSCode, one can install the Prettier 38 | extension and then in VScode's settings have the following entries: 39 | 40 | ```json 41 | "editor.formatOnSave": true, 42 | "[javascript]": { 43 | "editor.defaultFormatter": "esbenp.prettier-vscode" 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Bundle plugins 2 | 3 | Contains the code for Webpack and Rollup plugins to generate web bundles. 4 | 5 | See 6 | [packages/rollup-plugin-webbundle/README.md](./packages/rollup-plugin-webbundle/README.md) 7 | and 8 | [packages/webbundle-webpack-plugin/README.md](./packages/webbundle-webpack-plugin/README.md) 9 | for more details on how to use the plugins. 10 | 11 | ## Discuss & Help 12 | 13 | For discussions related to this repository's content, the Web Bundle plugins for 14 | webpack and rollup, please use 15 | [GitHub Issues](https://github.com/GoogleChromeLabs/webbundle-plugins/issues). 16 | 17 | If you'd like to discuss the Web Packaging proposal itself, consider opening an 18 | issue in its incubation repository at https://github.com/WICG/webpackage. 19 | 20 | For discussions related to Isolated Web Apps in general, or Chromium-specific 21 | implementation and development questions, please use the 22 | [iwa-dev@chromium.org](https://groups.google.com/a/chromium.org/g/iwa-dev) 23 | mailing list. 24 | 25 | If you'd like to discuss the Isolated Web Apps proposal, which builds on top of 26 | Web Bundles, consider opening an issue in the incubation repository at 27 | https://github.com/WICG/isolated-web-apps. 28 | 29 | ## License 30 | 31 | Licensed under the Apache-2.0 license. 32 | 33 | ## Contributing 34 | 35 | See [CONTRIBUTING.md](CONTRIBUTING.md) file. 36 | 37 | ## Disclaimer 38 | 39 | This is not an officially supported Google product. 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webbundle-plugins", 3 | "scripts": { 4 | "test": "npm run build && ava", 5 | "build": "npm run build --workspaces", 6 | "pack": "npm pack --workspace=rollup-plugin-webbundle --workspace=webbundle-webpack-plugin", 7 | "lint": "npm run lint:prettier && npm run lint:eslint", 8 | "lint:eslint": "eslint --ext .js,.ts,.cjs,.mjs .", 9 | "lint:prettier": "prettier --check {**/*,*}.{cjs,js,ts,md,json} --config ./package.json --no-error-on-unmatched-pattern", 10 | "format": "npm run format:prettier && npm run format:eslint", 11 | "format:eslint": "eslint --ext .js,.ts,.cjs,.mjs --fix .", 12 | "format:prettier": "prettier --write {**/*,*}.{cjs,js,ts,md,json} --config ./package.json --no-error-on-unmatched-pattern", 13 | "update-snapshots": "ava --update-snapshots" 14 | }, 15 | "type": "module", 16 | "workspaces": [ 17 | "packages/rollup-plugin-webbundle", 18 | "packages/shared", 19 | "packages/webbundle-webpack-plugin" 20 | ], 21 | "private": true, 22 | "license": "Apache-2.0", 23 | "devDependencies": { 24 | "@types/mime": "^3.0.1", 25 | "@types/node": "^18.16.0", 26 | "@typescript-eslint/eslint-plugin": "^5.59.1", 27 | "@typescript-eslint/parser": "^5.59.1", 28 | "ava": "^4.3.3", 29 | "esbuild": "^0.17.15", 30 | "eslint": "^8.37.0", 31 | "eslint-config-prettier": "^8.8.0", 32 | "eslint-plugin-header": "^3.1.1", 33 | "prettier": "2.8.0", 34 | "typescript": "^5.0.4" 35 | }, 36 | "engines": { 37 | "node": ">= 16.0.0" 38 | }, 39 | "prettier": { 40 | "tabWidth": 2, 41 | "semi": true, 42 | "singleQuote": true, 43 | "printWidth": 80, 44 | "proseWrap": "always" 45 | }, 46 | "ava": { 47 | "workerThreads": false, 48 | "files": [ 49 | "!**/fixtures/**" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/README.md: -------------------------------------------------------------------------------- 1 | # rollup-plugin-webbundle 2 | 3 | A Rollup plugin which generates 4 | [Web Bundles](https://wpack-wg.github.io/bundled-responses/draft-ietf-wpack-bundled-responses.html) 5 | output. Currently the spec is still a draft, so this package is also in alpha 6 | until the spec stabilizes. 7 | 8 | ## Requirements 9 | 10 | This plugin requires Node v14.0.0+ and Rollup v1.21.0+. 11 | 12 | ## Install 13 | 14 | Using npm: 15 | 16 | ```bash 17 | npm install rollup-plugin-webbundle --save-dev 18 | ``` 19 | 20 | ## Usage 21 | 22 | ### General Web Bundle 23 | 24 | This example assumes your application entry point is `src/index.js` and static 25 | files (including `index.html`) are located in `static` directory. 26 | 27 | ```js 28 | /* rollup.config.mjs */ 29 | import webbundle from 'rollup-plugin-webbundle'; 30 | 31 | export default { 32 | input: 'src/index.js', 33 | output: { 34 | dir: 'dist', 35 | format: 'esm', 36 | }, 37 | plugins: [ 38 | webbundle({ 39 | baseURL: 'https://example.com/', 40 | static: { dir: 'static' }, 41 | }), 42 | ], 43 | }; 44 | ``` 45 | 46 | A WBN file `dist/out.wbn` should be written. 47 | 48 | ### [Isolated Web App](https://github.com/WICG/isolated-web-apps/blob/main/README.md) (Signed Web Bundle) 49 | 50 | This example assumes your application entry point is `src/index.js`, static 51 | files (including `index.html`) are located in `static` directory and you have a 52 | `.env` file in the root directory with `ED25519KEY` defined in it. The example 53 | also requires installing `dotenv` npm package as a dev dependency. 54 | 55 | It is also required to have a 56 | [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) at 57 | `/.well-known/manifest.webmanifest`, which can be placed e.g. in the `static` 58 | directory. 59 | 60 | Also as in the below example, `baseURL` must be of format 61 | `isolated-app://${WEB_BUNDLE_ID}` for Isolated Web Apps. It can easily be 62 | generated from the private key with `WebBundleId` helper class from `wbn-sign` 63 | package. See 64 | [Scheme explainer](https://github.com/WICG/isolated-web-apps/blob/main/Scheme.md) 65 | for more details. Also note that providing `headerOverride` is optional. 66 | 67 | ```js 68 | /* rollup.config.mjs */ 69 | import webbundle from 'rollup-plugin-webbundle'; 70 | import * as wbnSign from 'wbn-sign'; 71 | import dotenv from 'dotenv'; 72 | dotenv.config({ path: './.env' }); 73 | 74 | export default async () => { 75 | const key = wbnSign.parsePemKey( 76 | process.env.ENC_ED25519KEY, 77 | await wbnSign.readPassphrase() 78 | ); 79 | 80 | return { 81 | input: 'src/index.js', 82 | output: { dir: 'dist', format: 'esm' }, 83 | plugins: [ 84 | webbundle({ 85 | baseURL: new wbnSign.WebBundleId( 86 | key 87 | ).serializeWithIsolatedWebAppOrigin(), 88 | static: { dir: 'public' }, 89 | output: 'signed.swbn', 90 | integrityBlockSign: { 91 | strategy: new wbnSign.NodeCryptoSigningStrategy(key), 92 | }, 93 | headerOverride: { 94 | 'cross-origin-embedder-policy': 'require-corp', 95 | 'cross-origin-opener-policy': 'same-origin', 96 | 'cross-origin-resource-policy': 'same-origin', 97 | 'content-security-policy': 98 | "base-uri 'none'; default-src 'self'; object-src 'none'; frame-src 'self' https: blob: data:; connect-src 'self' https: wss:; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' https: blob: data:; media-src 'self' https: blob: data:; font-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; require-trusted-types-for 'script';", 99 | }, 100 | }), 101 | ], 102 | }; 103 | }; 104 | ``` 105 | 106 | A signed web bundle (containing an 107 | [Integrity Block](https://github.com/WICG/webpackage/blob/main/explainers/integrity-signature.md)) 108 | should be written to `dist/signed.swbn`. 109 | 110 | ## Options 111 | 112 | ### `baseURL` 113 | 114 | Type: `string` 115 | 116 | Default: `''` 117 | 118 | Specifies the URL prefix prepended to the file names in the bundle. Non-empty 119 | baseURL must end with `/`. 120 | 121 | ### `formatVersion` 122 | 123 | Type: `string` 124 | 125 | Default: `b2` 126 | 127 | Specifies WebBundle format version. 128 | 129 | ### `primaryURL` 130 | 131 | Type: `string` 132 | 133 | Default: baseURL 134 | 135 | Specifies the bundle's main resource URL. If omitted, the value of the `baseURL` 136 | option is used. 137 | 138 | ### `static` 139 | 140 | Type: `{ dir: String, baseURL?: string }` 141 | 142 | If specified, files and subdirectories under `dir` will be added to the bundle. 143 | `baseURL` can be omitted and defaults to `Options.baseURL`. 144 | 145 | ### `output` 146 | 147 | Type: `string` 148 | 149 | Default: `out.wbn` 150 | 151 | Specifies the file name of the Web Bundle to emit. 152 | 153 | ### `integrityBlockSign` 154 | 155 | Type: 156 | 157 | - `{ key: KeyObject, isIwa?: boolean }` 158 | - `{ strategy: ISigningStrategy, isIwa?: boolean }` 159 | - `{ strategies: Array, webBundleId: string, isIwa?: boolean }` 160 | 161 | Object specifying the signing options with 162 | [Integrity Block](https://github.com/WICG/webpackage/blob/main/explainers/integrity-signature.md). 163 | 164 | ### `integrityBlockSign.key` 165 | 166 | Type: `KeyObject` 167 | 168 | An unencrypted ed25519 private key can be generated with: 169 | 170 | ```bash 171 | openssl genpkey -algorithm Ed25519 -out ed25519key.pem 172 | ``` 173 | 174 | For better security, one should prefer using passphrase-encrypted ed25519 175 | private keys. To encrypt an unencrypted private key, run: 176 | 177 | ```bash 178 | # encrypt the key (will ask for a passphrase, make sure to use a strong one) 179 | openssl pkcs8 -in ed25519key.pem -topk8 -out encrypted_ed25519key.pem 180 | 181 | # delete the unencrypted key 182 | rm ed25519key.pem 183 | ``` 184 | 185 | It can be parsed with an imported helper function `parsePemKey(...)` from 186 | `wbn-sign` npm package. For an encrypted private key there's also an async 187 | helper function (`readPassphrase()`) to prompt the user for the passphrase the 188 | key was encrypted with. 189 | 190 | ```js 191 | // For an unencrypted ed25519 key. 192 | const key = wbnSign.parsePemKey(process.env.ED25519KEY); 193 | 194 | // For an encrypted ed25519 key. 195 | const key = wbnSign.parsePemKey( 196 | process.env.ENC_ED25519KEY, 197 | await wbnSign.readPassphrase() 198 | ); 199 | ``` 200 | 201 | Note that in order for the key to be parsed correctly, it must contain the 202 | `BEGIN` and `END` headers and line breaks (`\n`). Below an example `.env` file: 203 | 204 | ```bash 205 | ED25519KEY="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIB8nP5PpWU7HiILHSfh5PYzb5GAcIfHZ+bw6tcd/LZXh\n-----END PRIVATE KEY-----" 206 | ``` 207 | 208 | ### `integrityBlockSign.strategy` 209 | 210 | Type: `ISigningStrategy` 211 | 212 | Example web bundle plugin options using a signing strategy: 213 | 214 | ```js 215 | const pluginOptionsWithPredefinedSigningStrategy = { 216 | // ...other plugin options here... 217 | integrityBlockSign: { 218 | strategy: new wbnSign.NodeCryptoSigningStrategy(privateKey), 219 | }, 220 | }; 221 | 222 | const pluginOptionsWithCustomSigningStrategy = { 223 | // ...other plugin options here... 224 | integrityBlockSign: { 225 | strategy: new (class /* implements ISigningStrategy */ { 226 | async sign(data) { 227 | /** E.g. connect to one's external signing service that signs the 228 | * payload. */ 229 | } 230 | async getPublicKey() { 231 | /** E.g. connect to one's external signing service that returns the 232 | * public key. */ 233 | } 234 | })(), 235 | }, 236 | }; 237 | ``` 238 | 239 | ### `integrityBlockSign.strategies` 240 | 241 | Type: `Array` 242 | 243 | Use this overload to sign a bundle with multiple keys. Note that `webBundleId` 244 | must always be specified when using `strategies`. 245 | 246 | ``` 247 | const pluginOptionsWithMultipleStrategiesAndWebBundleId = { 248 | // ...other plugin options here... 249 | integrityBlockSign: { 250 | strategies: [ 251 | new NodeCryptoSigningStrategy(privateKey1), 252 | new NodeCryptoSigningStrategy(privateKey2) 253 | ], 254 | webBundleId: "some-random-id" 255 | }, 256 | }; 257 | ``` 258 | 259 | ### `integrityBlockSign.webBundleId` 260 | 261 | Type: `string` 262 | 263 | Allows specifying a custom id for this signed web bundle to decouple it from the 264 | signing keys. Must be used together with `strategies`. 265 | 266 | ### `integrityBlockSign.isIwa` (optional) 267 | 268 | Type: `boolean` 269 | 270 | Default: `true` 271 | 272 | If `undefined` or `true`, enforces certain 273 | [Isolated Web App](https://github.com/WICG/isolated-web-apps) -related checks 274 | for the headers. Also adds default IWA headers if completely missing. If set to 275 | `false`, skips validation checks and doesn't tamper with the headers. 276 | 277 | ### `headerOverride` (optional) 278 | 279 | Type: `{ [key: string]: string; }` | 280 | `(filepath: string) => { [key: string]: string; };` 281 | 282 | Object of strings specifying overridden headers or a function returning the same 283 | kind of object. 284 | 285 | ## Discuss & Help 286 | 287 | For discussions related to this repository's content, the Web Bundle plugins for 288 | webpack and rollup, please use 289 | [GitHub Issues](https://github.com/GoogleChromeLabs/webbundle-plugins/issues). 290 | 291 | If you'd like to discuss the Web Packaging proposal itself, consider opening an 292 | issue in its incubation repository at https://github.com/WICG/webpackage. 293 | 294 | For discussions related to Isolated Web Apps in general, or Chromium-specific 295 | implementation and development questions, please use the 296 | [iwa-dev@chromium.org](https://groups.google.com/a/chromium.org/g/iwa-dev) 297 | mailing list. 298 | 299 | If you'd like to discuss the Isolated Web Apps proposal, which builds on top of 300 | Web Bundles, consider opening an issue in the incubation repository at 301 | https://github.com/WICG/isolated-web-apps. 302 | 303 | ## Release Notes 304 | 305 | ### v0.2.0 306 | 307 | - Add support for the v2 integrity block format. Now web-bundle-id is no longer 308 | presumed to be a derivative of the first public key in the stack, but rather 309 | acts as a separate entry in the integrity block attributes, and multiple 310 | independent signatures are allowed to facilitate key rotation. 311 | 312 | ### v0.1.4 313 | 314 | - Add support for ECDSA P-256 SHA-256 signing algorithm 315 | - Bumping underlying wbn-sign version to v0.1.3. 316 | 317 | ### v0.1.3 318 | 319 | - Updates to style-src and wss CSP values. 320 | - Bumping underlying wbn-sign version to v0.1.1. 321 | 322 | ### v0.1.2 323 | 324 | - Add support for `integrityBlockSign.strategy` plugin option which can be used 325 | to pass one of the predefined strategies or one's own implementation class for 326 | ISigningStrategy. One can also use the old `integrityBlockSign.key` option, 327 | which defaults to the predefined `NodeCryptoSigningStrategy` strategy. 328 | - Refactor plugin to be in TypeScript. 329 | - Combine the Webpack and Rollup web bundle plugins to live in the same 330 | repository and share some duplicated code. Taking advantage of 331 | [npm workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces). 332 | 333 | ### v0.1.1 334 | 335 | - Add support for overriding headers with an optional `headerOverride` plugin 336 | option. 337 | 338 | ### v0.1.0 339 | 340 | - BREAKING CHANGE: Change type of integrityBlockSign.key to be KeyObject instead 341 | of string. 342 | - Upgrade to support Rollup 3. 343 | 344 | ### v0.0.4 345 | 346 | - Support for signing web bundles with 347 | [integrity block](https://github.com/WICG/webpackage/blob/main/explainers/integrity-signature.md) 348 | added. 349 | 350 | ## License 351 | 352 | Licensed under the Apache-2.0 license. 353 | 354 | ## Contributing 355 | 356 | See [CONTRIBUTING.md](../../CONTRIBUTING.md) file. 357 | 358 | ## Disclaimer 359 | 360 | This is not an officially supported Google product. 361 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-webbundle", 3 | "version": "0.2.0", 4 | "description": "Rollup plugin to generate WebBundle output.", 5 | "keywords": [ 6 | "rollup-plugin", 7 | "web-bundle", 8 | "isolated-web-app" 9 | ], 10 | "main": "lib/index.js", 11 | "scripts": { 12 | "prepack": "npm run build && cp ../../LICENSE ./LICENSE", 13 | "postpack": "rm -f ./LICENSE", 14 | "build": "rm -rf lib && esbuild --bundle --packages=external --format=esm --outfile=lib/index.js src/index.ts --platform=node --legal-comments=inline --sourcemap --keep-names" 15 | }, 16 | "type": "module", 17 | "author": "Kunihiko Sakamoto ", 18 | "contributors": [ 19 | "Sonja Laurila (https://github.com/sonkkeli)", 20 | "Christian Flach (https://github.com/cmfcmf)", 21 | "Andrew Rayskiy (https://github.com/GreenGrape)" 22 | ], 23 | "license": "Apache-2.0", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/GoogleChromeLabs/webbundle-plugins.git", 27 | "directory": "packages/rollup-plugin-webbundle" 28 | }, 29 | "peerDependencies": { 30 | "rollup": ">=1.21.0 <4.0.0" 31 | }, 32 | "dependencies": { 33 | "mime": "^2.4.4", 34 | "wbn": "0.0.9", 35 | "wbn-sign": "0.2.0", 36 | "zod": "^3.21.4" 37 | }, 38 | "devDependencies": { 39 | "rollup": "^3.29.5" 40 | }, 41 | "engines": { 42 | "node": ">= 16.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/src/index.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { BundleBuilder } from 'wbn'; 18 | import { Plugin, OutputOptions } from 'rollup'; 19 | import { 20 | addAsset, 21 | addFilesRecursively, 22 | getSignedWebBundle, 23 | } from '../../shared/utils'; 24 | import { 25 | getValidatedOptionsWithDefaults, 26 | PluginOptions, 27 | } from '../../shared/types'; 28 | 29 | const consoleLogColor = { green: '\x1b[32m', reset: '\x1b[0m' }; 30 | function infoLogger(text: string): void { 31 | console.log(`${consoleLogColor.green}${text}${consoleLogColor.reset}\n`); 32 | } 33 | 34 | // TODO(sonkkeli): Probably this depends on the Rollup version. Figure out how 35 | // this should be refactored. 36 | // https://rollupjs.org/plugin-development/#build-hooks 37 | type EnforcedPlugin = Plugin & { enforce: 'post' | 'pre' | null }; 38 | 39 | export default function wbnOutputPlugin( 40 | rawOpts: PluginOptions 41 | ): EnforcedPlugin { 42 | return { 43 | name: 'wbn-output-plugin', 44 | enforce: 'post', 45 | 46 | async generateBundle(_: OutputOptions, bundle): Promise { 47 | const opts = await getValidatedOptionsWithDefaults(rawOpts); 48 | 49 | const builder = new BundleBuilder(opts.formatVersion); 50 | if ('primaryURL' in opts && opts.primaryURL) { 51 | builder.setPrimaryURL(opts.primaryURL); 52 | } 53 | 54 | if (opts.static) { 55 | addFilesRecursively( 56 | builder, 57 | opts.static.baseURL ?? opts.baseURL, 58 | opts.static.dir, 59 | opts 60 | ); 61 | } 62 | 63 | for (const name of Object.keys(bundle)) { 64 | const asset = bundle[name]; 65 | const content = asset.type === 'asset' ? asset.source : asset.code; 66 | addAsset( 67 | builder, 68 | opts.baseURL, 69 | asset.fileName, // This contains the relative path to the base dir already. 70 | content, 71 | opts 72 | ); 73 | delete bundle[name]; 74 | } 75 | 76 | let webBundle = builder.createBundle(); 77 | if ('integrityBlockSign' in opts) { 78 | webBundle = await getSignedWebBundle(webBundle, opts, infoLogger); 79 | } 80 | 81 | this.emitFile({ 82 | fileName: opts.output, 83 | type: 'asset', 84 | source: Buffer.from( 85 | webBundle, 86 | webBundle.byteOffset, 87 | webBundle.byteLength 88 | ), 89 | }); 90 | }, 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export default 42; 18 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/test/fixtures/static/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/test/snapshots/test.js.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/rollup-plugin-webbundle/test/test.js` 2 | 3 | The actual snapshot is saved in `test.js.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## simple 8 | 9 | > Snapshot 1 10 | 11 | { 12 | exchanges: { 13 | 'https://wbn.example.com/index.js': { 14 | body: `/*!␊ 15 | * Copyright 2020 Google LLC␊ 16 | *␊ 17 | * Licensed under the Apache License, Version 2.0 (the "License");␊ 18 | * you may not use this file except in compliance with the License.␊ 19 | * You may obtain a copy of the License at␊ 20 | *␊ 21 | * http://www.apache.org/licenses/LICENSE-2.0␊ 22 | *␊ 23 | * Unless required by applicable law or agreed to in writing, software␊ 24 | * distributed under the License is distributed on an "AS IS" BASIS,␊ 25 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.␊ 26 | * See the License for the specific language governing permissions and␊ 27 | * limitations under the License.␊ 28 | */␊ 29 | ␊ 30 | var index = 42;␊ 31 | ␊ 32 | export { index as default };␊ 33 | `, 34 | headers: { 35 | 'content-type': 'application/javascript', 36 | }, 37 | status: 200, 38 | }, 39 | }, 40 | primaryURL: 'https://wbn.example.com/index.js', 41 | version: 'b2', 42 | } 43 | 44 | ## asset 45 | 46 | > Snapshot 1 47 | 48 | { 49 | exchanges: { 50 | 'https://wbn.example.com/assets/hello.txt': { 51 | body: 'Hello', 52 | headers: { 53 | 'content-type': 'text/plain', 54 | }, 55 | status: 200, 56 | }, 57 | 'https://wbn.example.com/index.js': { 58 | body: `/*!␊ 59 | * Copyright 2020 Google LLC␊ 60 | *␊ 61 | * Licensed under the Apache License, Version 2.0 (the "License");␊ 62 | * you may not use this file except in compliance with the License.␊ 63 | * You may obtain a copy of the License at␊ 64 | *␊ 65 | * http://www.apache.org/licenses/LICENSE-2.0␊ 66 | *␊ 67 | * Unless required by applicable law or agreed to in writing, software␊ 68 | * distributed under the License is distributed on an "AS IS" BASIS,␊ 69 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.␊ 70 | * See the License for the specific language governing permissions and␊ 71 | * limitations under the License.␊ 72 | */␊ 73 | ␊ 74 | var index = 42;␊ 75 | ␊ 76 | export { index as default };␊ 77 | `, 78 | headers: { 79 | 'content-type': 'application/javascript', 80 | }, 81 | status: 200, 82 | }, 83 | }, 84 | primaryURL: 'https://wbn.example.com/assets/hello.txt', 85 | version: 'b1', 86 | } 87 | 88 | ## static 89 | 90 | > Snapshot 1 91 | 92 | { 93 | exchanges: { 94 | 'https://wbn.example.com/': { 95 | body: '', 96 | headers: { 97 | 'content-type': 'text/html', 98 | }, 99 | status: 200, 100 | }, 101 | 'https://wbn.example.com/index.html': { 102 | body: '', 103 | headers: { 104 | location: './', 105 | }, 106 | status: 301, 107 | }, 108 | 'https://wbn.example.com/index.js': { 109 | body: `/*!␊ 110 | * Copyright 2020 Google LLC␊ 111 | *␊ 112 | * Licensed under the Apache License, Version 2.0 (the "License");␊ 113 | * you may not use this file except in compliance with the License.␊ 114 | * You may obtain a copy of the License at␊ 115 | *␊ 116 | * http://www.apache.org/licenses/LICENSE-2.0␊ 117 | *␊ 118 | * Unless required by applicable law or agreed to in writing, software␊ 119 | * distributed under the License is distributed on an "AS IS" BASIS,␊ 120 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.␊ 121 | * See the License for the specific language governing permissions and␊ 122 | * limitations under the License.␊ 123 | */␊ 124 | ␊ 125 | var index = 42;␊ 126 | ␊ 127 | export { index as default };␊ 128 | `, 129 | headers: { 130 | 'content-type': 'application/javascript', 131 | }, 132 | status: 200, 133 | }, 134 | }, 135 | primaryURL: 'https://wbn.example.com/', 136 | version: 'b2', 137 | } 138 | 139 | ## relative 140 | 141 | > Snapshot 1 142 | 143 | { 144 | exchanges: { 145 | '/': { 146 | body: '', 147 | headers: { 148 | 'content-type': 'text/html', 149 | }, 150 | status: 200, 151 | }, 152 | '/index.html': { 153 | body: '', 154 | headers: { 155 | location: './', 156 | }, 157 | status: 301, 158 | }, 159 | '/index.js': { 160 | body: `/*!␊ 161 | * Copyright 2020 Google LLC␊ 162 | *␊ 163 | * Licensed under the Apache License, Version 2.0 (the "License");␊ 164 | * you may not use this file except in compliance with the License.␊ 165 | * You may obtain a copy of the License at␊ 166 | *␊ 167 | * http://www.apache.org/licenses/LICENSE-2.0␊ 168 | *␊ 169 | * Unless required by applicable law or agreed to in writing, software␊ 170 | * distributed under the License is distributed on an "AS IS" BASIS,␊ 171 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.␊ 172 | * See the License for the specific language governing permissions and␊ 173 | * limitations under the License.␊ 174 | */␊ 175 | ␊ 176 | var index = 42;␊ 177 | ␊ 178 | export { index as default };␊ 179 | `, 180 | headers: { 181 | 'content-type': 'application/javascript', 182 | }, 183 | status: 200, 184 | }, 185 | }, 186 | primaryURL: null, 187 | version: 'b2', 188 | } 189 | -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/test/snapshots/test.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/webbundle-plugins/fa2b3a0bda6a6e3db7570fb45dd8a9a2f303267d/packages/rollup-plugin-webbundle/test/snapshots/test.js.snap -------------------------------------------------------------------------------- /packages/rollup-plugin-webbundle/test/test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'ava'; 18 | import * as path from 'path'; 19 | import * as rollup from 'rollup'; 20 | import crypto from 'crypto'; 21 | import url from 'url'; 22 | import * as wbn from 'wbn'; 23 | import * as wbnSign from 'wbn-sign'; 24 | import webbundle from '../lib/index.js'; 25 | import { 26 | coep, 27 | coop, 28 | corp, 29 | csp, 30 | iwaHeaderDefaults, 31 | } from '../../shared/lib/iwa-headers.js'; 32 | 33 | const TEST_ED25519_PRIVATE_KEY = wbnSign.parsePemKey( 34 | '-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIB8nP5PpWU7HiILHSfh5PYzb5GAcIfHZ+bw6tcd/LZXh\n-----END PRIVATE KEY-----' 35 | ); 36 | const TEST_IWA_BASE_URL = 37 | 'isolated-app://4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic/'; 38 | 39 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 40 | process.chdir(__dirname); 41 | 42 | function parseWebBundle(buf) { 43 | const bundle = new wbn.Bundle(buf); 44 | const exchanges = {}; 45 | for (const url of bundle.urls) { 46 | const resp = bundle.getResponse(url); 47 | let body = new TextDecoder('utf-8').decode(resp.body); 48 | 49 | // Our test snapshots are generated with Rollup 2, but Rollup 1 uses 50 | // different syntax for default export. 51 | if (rollup.VERSION.startsWith('1.')) { 52 | body = body.replace( 53 | 'export default index', 54 | 'export { index as default }' 55 | ); 56 | } 57 | 58 | exchanges[url] = { 59 | status: resp.status, 60 | headers: resp.headers, 61 | body: body, 62 | }; 63 | } 64 | return { 65 | version: bundle.version, 66 | primaryURL: bundle.primaryURL, 67 | exchanges, 68 | }; 69 | } 70 | 71 | test('simple', async (t) => { 72 | const bundle = await rollup.rollup({ 73 | input: 'fixtures/index.js', 74 | plugins: [ 75 | webbundle({ 76 | baseURL: 'https://wbn.example.com/', 77 | primaryURL: 'https://wbn.example.com/index.js', 78 | output: 'out.wbn', 79 | }), 80 | ], 81 | }); 82 | const { output } = await bundle.generate({ format: 'esm' }); 83 | const keys = Object.keys(output); 84 | t.is(keys.length, 1); 85 | if (output[keys[0]].type) t.is(output[keys[0]].type, 'asset'); 86 | else t.true(output[keys[0]].isAsset); 87 | t.is(output[keys[0]].fileName, 'out.wbn'); 88 | 89 | t.snapshot(parseWebBundle(output[keys[0]].source)); 90 | }); 91 | 92 | test('asset', async (t) => { 93 | const bundle = await rollup.rollup({ 94 | input: 'fixtures/index.js', 95 | plugins: [ 96 | { 97 | name: 'add-asset', 98 | generateBundle() { 99 | this.emitFile({ 100 | type: 'asset', 101 | name: 'hello.txt', 102 | source: 'Hello', 103 | }); 104 | }, 105 | }, 106 | webbundle({ 107 | formatVersion: 'b1', 108 | baseURL: 'https://wbn.example.com/', 109 | primaryURL: 'https://wbn.example.com/assets/hello.txt', 110 | output: 'out.wbn', 111 | }), 112 | ], 113 | }); 114 | const { output } = await bundle.generate({ 115 | format: 'esm', 116 | assetFileNames: 'assets/[name][extname]', 117 | }); 118 | const keys = Object.keys(output); 119 | t.is(keys.length, 1); 120 | if (output[keys[0]].type) t.is(output[keys[0]].type, 'asset'); 121 | else t.true(output[keys[0]].isAsset); 122 | t.is(output[keys[0]].fileName, 'out.wbn'); 123 | 124 | t.snapshot(parseWebBundle(output[keys[0]].source)); 125 | }); 126 | 127 | test('static', async (t) => { 128 | const bundle = await rollup.rollup({ 129 | input: 'fixtures/index.js', 130 | plugins: [ 131 | webbundle({ 132 | baseURL: 'https://wbn.example.com/', 133 | primaryURL: 'https://wbn.example.com/', 134 | output: 'out.wbn', 135 | static: { dir: 'fixtures/static' }, 136 | }), 137 | ], 138 | }); 139 | const { output } = await bundle.generate({ format: 'esm' }); 140 | const keys = Object.keys(output); 141 | t.is(keys.length, 1); 142 | if (output[keys[0]].type) t.is(output[keys[0]].type, 'asset'); 143 | else t.true(output[keys[0]].isAsset); 144 | t.is(output[keys[0]].fileName, 'out.wbn'); 145 | 146 | t.snapshot(parseWebBundle(output[keys[0]].source)); 147 | }); 148 | 149 | test('relative', async (t) => { 150 | const bundle = await rollup.rollup({ 151 | input: 'fixtures/index.js', 152 | plugins: [ 153 | webbundle({ 154 | baseURL: '/', 155 | output: 'out.wbn', 156 | static: { dir: 'fixtures/static' }, 157 | }), 158 | ], 159 | }); 160 | const { output } = await bundle.generate({ format: 'esm' }); 161 | const keys = Object.keys(output); 162 | t.is(keys.length, 1); 163 | if (output[keys[0]].type) t.is(output[keys[0]].type, 'asset'); 164 | else t.true(output[keys[0]].isAsset); 165 | t.is(output[keys[0]].fileName, 'out.wbn'); 166 | 167 | t.snapshot(parseWebBundle(output[keys[0]].source)); 168 | }); 169 | 170 | test('integrityBlockSign', async (t) => { 171 | const outputFileName = 'out.swbn'; 172 | 173 | const testCases = [ 174 | // With default signer. 175 | { 176 | key: TEST_ED25519_PRIVATE_KEY, 177 | }, 178 | // With signer option specified. 179 | { 180 | strategy: new wbnSign.NodeCryptoSigningStrategy(TEST_ED25519_PRIVATE_KEY), 181 | }, 182 | ]; 183 | 184 | for (const testCase of testCases) { 185 | const bundle = await rollup.rollup({ 186 | input: 'fixtures/index.js', 187 | plugins: [ 188 | webbundle({ 189 | baseURL: TEST_IWA_BASE_URL, 190 | output: outputFileName, 191 | integrityBlockSign: testCase, 192 | }), 193 | ], 194 | }); 195 | const { output } = await bundle.generate({ format: 'esm' }); 196 | const keys = Object.keys(output); 197 | t.is(keys.length, 1); 198 | t.is(output[keys[0]].fileName, outputFileName); 199 | 200 | const swbnFile = output[keys[0]].source; 201 | const wbnLength = Number(Buffer.from(swbnFile.slice(-8)).readBigUint64BE()); 202 | t.truthy(wbnLength < swbnFile.length); 203 | 204 | const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner( 205 | /*is_v2=*/ true, 206 | swbnFile.slice(-wbnLength), 207 | new wbnSign.WebBundleId(TEST_ED25519_PRIVATE_KEY).serialize(), 208 | [new wbnSign.NodeCryptoSigningStrategy(TEST_ED25519_PRIVATE_KEY)] 209 | ).sign(); 210 | 211 | t.deepEqual(swbnFile, Buffer.from(signedWebBundle)); 212 | } 213 | }); 214 | 215 | test('headerOverride - IWA with good headers', async (t) => { 216 | const headersTestCases = [ 217 | // These are added manually as they expect more than just `iwaHeaderDefaults`. 218 | { 219 | headerOverride: { 220 | ...iwaHeaderDefaults, 221 | 'X-Csrf-Token': 'hello-world', 222 | }, 223 | expectedHeaders: { 224 | ...iwaHeaderDefaults, 225 | 'x-csrf-token': 'hello-world', 226 | }, 227 | }, 228 | { 229 | headerOverride: () => { 230 | return { 231 | ...iwaHeaderDefaults, 232 | 'X-Csrf-Token': 'hello-world', 233 | }; 234 | }, 235 | expectedHeaders: { 236 | ...iwaHeaderDefaults, 237 | 'x-csrf-token': 'hello-world', 238 | }, 239 | }, 240 | ]; 241 | 242 | const headersThatDefaultToIWADefaults = [ 243 | { ...coop, ...corp, ...csp }, 244 | { ...coep, ...corp, ...csp }, 245 | { ...coep, ...coop, ...csp }, 246 | { ...coep, ...coop, ...corp }, 247 | iwaHeaderDefaults, 248 | {}, 249 | undefined, 250 | { 251 | ...iwaHeaderDefaults, 252 | 'Cross-Origin-Embedder-Policy': 'require-corp', 253 | }, 254 | ]; 255 | 256 | for (const headers of headersThatDefaultToIWADefaults) { 257 | // Both functions and objects are ok so let's test with both. 258 | headersTestCases.push({ 259 | headerOverride: headers, 260 | expectedHeaders: iwaHeaderDefaults, 261 | }); 262 | 263 | // Not supported as typeof function because that's forced to return `Headers` map. 264 | if (headers === undefined) continue; 265 | headersTestCases.push({ 266 | headerOverride: () => headers, 267 | expectedHeaders: iwaHeaderDefaults, 268 | }); 269 | } 270 | 271 | const outputFileName = 'out.swbn'; 272 | for (const headersTestCase of headersTestCases) { 273 | for (const isIwaTestCase of [undefined, true]) { 274 | const bundle = await rollup.rollup({ 275 | input: 'fixtures/index.js', 276 | plugins: [ 277 | webbundle({ 278 | baseURL: TEST_IWA_BASE_URL, 279 | output: outputFileName, 280 | integrityBlockSign: { 281 | key: TEST_ED25519_PRIVATE_KEY, 282 | isIwa: isIwaTestCase, 283 | }, 284 | headerOverride: headersTestCase.headerOverride, 285 | }), 286 | ], 287 | }); 288 | const { output } = await bundle.generate({ format: 'esm' }); 289 | const keys = Object.keys(output); 290 | t.is(keys.length, 1); 291 | t.is(output[keys[0]].fileName, outputFileName); 292 | 293 | const swbnFile = output[keys[0]].source; 294 | const wbnLength = Number( 295 | Buffer.from(swbnFile.slice(-8)).readBigUint64BE() 296 | ); 297 | t.truthy(wbnLength < swbnFile.length); 298 | 299 | const usignedBundle = new wbn.Bundle(swbnFile.slice(-wbnLength)); 300 | for (const url of usignedBundle.urls) { 301 | for (const [headerName, headerValue] of Object.entries( 302 | iwaHeaderDefaults 303 | )) { 304 | t.is(usignedBundle.getResponse(url).headers[headerName], headerValue); 305 | } 306 | } 307 | } 308 | } 309 | }); 310 | 311 | test("headerOverride - non-IWA doesn't enforce IWA headers", async (t) => { 312 | // Irrelevant what this would contain. 313 | const randomNonIwaHeaders = { 'x-csrf-token': 'hello-world' }; 314 | 315 | const headersTestCases = [ 316 | { 317 | // Type `object` is ok. 318 | headerOverride: randomNonIwaHeaders, 319 | expectedHeaders: randomNonIwaHeaders, 320 | }, 321 | { 322 | // Same but camel case, which gets lower-cased. 323 | headerOverride: { 'X-Csrf-Token': 'hello-world' }, 324 | expectedHeaders: randomNonIwaHeaders, 325 | }, 326 | { 327 | // Type `function` is ok. 328 | headerOverride: () => randomNonIwaHeaders, 329 | expectedHeaders: randomNonIwaHeaders, 330 | }, 331 | { 332 | // When `integrityBlockSign.isIwa` is false and `headerOverride` is 333 | // `undefined`, nothing unusual gets added. 334 | headerOverride: undefined, 335 | expectedHeaders: {}, 336 | }, 337 | ]; 338 | 339 | const outputFileName = 'out.swbn'; 340 | for (const headersTestCase of headersTestCases) { 341 | const bundle = await rollup.rollup({ 342 | input: 'fixtures/index.js', 343 | plugins: [ 344 | webbundle({ 345 | baseURL: TEST_IWA_BASE_URL, 346 | output: outputFileName, 347 | integrityBlockSign: { 348 | key: TEST_ED25519_PRIVATE_KEY, 349 | isIwa: false, 350 | }, 351 | headerOverride: headersTestCase.headerOverride, 352 | }), 353 | ], 354 | }); 355 | 356 | const { output } = await bundle.generate({ format: 'esm' }); 357 | const keys = Object.keys(output); 358 | t.is(keys.length, 1); 359 | t.is(output[keys[0]].fileName, outputFileName); 360 | const swbnFile = output[keys[0]].source; 361 | 362 | const wbnLength = Number(Buffer.from(swbnFile.slice(-8)).readBigUint64BE()); 363 | t.truthy(wbnLength < swbnFile.length); 364 | 365 | const usignedBundle = new wbn.Bundle(swbnFile.slice(-wbnLength)); 366 | for (const url of usignedBundle.urls) { 367 | // Added the expected headers. 368 | for (const [headerName, headerValue] of Object.entries( 369 | headersTestCase.expectedHeaders 370 | )) { 371 | t.is(usignedBundle.getResponse(url).headers[headerName], headerValue); 372 | } 373 | // Did not add any IWA headers automatically. 374 | for (const headerName of Object.keys(iwaHeaderDefaults)) { 375 | t.is(usignedBundle.getResponse(url).headers[headerName], undefined); 376 | } 377 | } 378 | } 379 | }); 380 | 381 | test("integrityBlockSign with undefined baseURL doesn't fail", async (t) => { 382 | const outputFileName = 'out.swbn'; 383 | 384 | const bundle = await rollup.rollup({ 385 | input: 'fixtures/index.js', 386 | plugins: [ 387 | webbundle({ 388 | output: outputFileName, 389 | integrityBlockSign: { 390 | strategy: new wbnSign.NodeCryptoSigningStrategy( 391 | TEST_ED25519_PRIVATE_KEY 392 | ), 393 | }, 394 | }), 395 | ], 396 | }); 397 | const { output } = await bundle.generate({ format: 'esm' }); 398 | const keys = Object.keys(output); 399 | t.is(keys.length, 1); 400 | t.is(output[keys[0]].fileName, outputFileName); 401 | }); 402 | 403 | // To make sure actually async things work. 404 | test('integrityBlockSign with sleeping CustomSigningStrategy', async (t) => { 405 | function sleep(ms) { 406 | return new Promise((resolve) => { 407 | setTimeout(resolve, ms); 408 | }); 409 | } 410 | 411 | class CustomSigningStrategy { 412 | async sign(data) { 413 | await sleep(500); 414 | return crypto.sign( 415 | /*algorithm=*/ undefined, 416 | data, 417 | TEST_ED25519_PRIVATE_KEY 418 | ); 419 | } 420 | 421 | async getPublicKey() { 422 | await sleep(500); 423 | return crypto.createPublicKey(TEST_ED25519_PRIVATE_KEY); 424 | } 425 | } 426 | 427 | const outputFileName = 'out.swbn'; 428 | 429 | const bundle = await rollup.rollup({ 430 | input: 'fixtures/index.js', 431 | plugins: [ 432 | webbundle({ 433 | output: outputFileName, 434 | integrityBlockSign: { 435 | strategy: new CustomSigningStrategy(), 436 | }, 437 | }), 438 | ], 439 | }); 440 | const { output } = await bundle.generate({ format: 'esm' }); 441 | const keys = Object.keys(output); 442 | t.is(keys.length, 1); 443 | t.is(output[keys[0]].fileName, outputFileName); 444 | }); 445 | 446 | test('integrityBlockSign with multiple strategies', async (t) => { 447 | // ECDSA P-256 SHA-256 signatures are not deterministic, so this test uses 448 | // only Ed25519 ones for generating bundles side-by-side. 449 | const keyPairs = [ 450 | crypto.generateKeyPairSync('ed25519'), 451 | crypto.generateKeyPairSync('ed25519'), 452 | crypto.generateKeyPairSync('ed25519'), 453 | ]; 454 | 455 | const webBundleId = new wbnSign.WebBundleId(keyPairs[0].privateKey); 456 | const strategies = keyPairs.map( 457 | (keyPair) => new wbnSign.NodeCryptoSigningStrategy(keyPair.privateKey) 458 | ); 459 | 460 | const outputFileName = 'out.swbn'; 461 | const bundle = await rollup.rollup({ 462 | input: 'fixtures/index.js', 463 | plugins: [ 464 | webbundle({ 465 | baseURL: webBundleId.serializeWithIsolatedWebAppOrigin(), 466 | output: outputFileName, 467 | integrityBlockSign: { 468 | webBundleId: webBundleId.serialize(), 469 | strategies, 470 | }, 471 | }), 472 | ], 473 | }); 474 | const { output } = await bundle.generate({ format: 'esm' }); 475 | const keys = Object.keys(output); 476 | t.is(keys.length, 1); 477 | t.is(output[keys[0]].fileName, outputFileName); 478 | 479 | const swbnFile = output[keys[0]].source; 480 | const wbnLength = Number(Buffer.from(swbnFile.slice(-8)).readBigUint64BE()); 481 | t.truthy(wbnLength < swbnFile.length); 482 | 483 | const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner( 484 | /*is_v2=*/ true, 485 | swbnFile.slice(-wbnLength), 486 | webBundleId.serialize(), 487 | strategies 488 | ).sign(); 489 | 490 | t.deepEqual(swbnFile, Buffer.from(signedWebBundle)); 491 | }); 492 | -------------------------------------------------------------------------------- /packages/shared/iwa-headers.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Headers } from './types'; 18 | 19 | export const coep: Headers = Object.freeze({ 20 | 'cross-origin-embedder-policy': 'require-corp', 21 | }); 22 | export const coop: Headers = Object.freeze({ 23 | 'cross-origin-opener-policy': 'same-origin', 24 | }); 25 | export const corp: Headers = Object.freeze({ 26 | 'cross-origin-resource-policy': 'same-origin', 27 | }); 28 | 29 | export const CSP_HEADER_NAME = 'content-security-policy'; 30 | export const csp: Headers = Object.freeze({ 31 | [CSP_HEADER_NAME]: 32 | "base-uri 'none'; default-src 'self'; object-src 'none'; frame-src 'self' https: blob: data:; connect-src 'self' https: wss:; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' https: blob: data:; media-src 'self' https: blob: data:; font-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; require-trusted-types-for 'script'; frame-ancestors 'self';", 33 | }); 34 | 35 | // These headers must have these exact values for Isolated Web Apps, whereas the 36 | // CSP header can also be more strict. 37 | const invariableIwaHeaders: Headers = Object.freeze({ 38 | ...coep, 39 | ...coop, 40 | ...corp, 41 | }); 42 | 43 | export const iwaHeaderDefaults: Headers = Object.freeze({ 44 | ...csp, 45 | ...invariableIwaHeaders, 46 | }); 47 | 48 | function headerNamesToLowerCase(headers: Headers): Headers { 49 | const lowerCaseHeaders: Headers = {}; 50 | for (const [headerName, headerValue] of Object.entries(headers)) { 51 | lowerCaseHeaders[headerName.toLowerCase()] = headerValue; 52 | } 53 | return lowerCaseHeaders; 54 | } 55 | 56 | const ifNotIwaMsg = 57 | "If you are bundling a non-IWA, set `integrityBlockSign: { isIwa: false }` in the plugin's configuration."; 58 | 59 | // Checks if the IWA headers are strict enough or adds in case missing. 60 | export function checkAndAddIwaHeaders(headers: Headers) { 61 | const lowerCaseHeaders = headerNamesToLowerCase(headers); 62 | 63 | // Add missing IWA headers. 64 | for (const [iwaHeaderName, iwaHeaderValue] of Object.entries( 65 | iwaHeaderDefaults 66 | )) { 67 | if (!lowerCaseHeaders[iwaHeaderName]) { 68 | console.log( 69 | `For Isolated Web Apps, ${iwaHeaderName} header was automatically set to ${iwaHeaderValue}. ${ifNotIwaMsg}` 70 | ); 71 | headers[iwaHeaderName] = iwaHeaderValue; 72 | } 73 | } 74 | 75 | // Check strictness of IWA headers (apart from special case `Content-Security-Policy`). 76 | for (const [iwaHeaderName, iwaHeaderValue] of Object.entries( 77 | invariableIwaHeaders 78 | )) { 79 | if ( 80 | lowerCaseHeaders[iwaHeaderName] && 81 | lowerCaseHeaders[iwaHeaderName].toLowerCase() !== iwaHeaderValue 82 | ) { 83 | throw new Error( 84 | `For Isolated Web Apps ${iwaHeaderName} should be ${iwaHeaderValue}. Now it is ${headers[iwaHeaderName]}. ${ifNotIwaMsg}` 85 | ); 86 | } 87 | } 88 | 89 | // TODO: Parse and check strictness of `Content-Security-Policy`. 90 | } 91 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "rm -rf lib && tsc" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/shared/test/test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'ava'; 18 | import { getValidatedOptionsWithDefaults } from '../lib/types.js'; 19 | import * as wbnSign from 'wbn-sign'; 20 | 21 | const TEST_ED25519_PRIVATE_KEY = wbnSign.parsePemKey( 22 | '-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIB8nP5PpWU7HiILHSfh5PYzb5GAcIfHZ+bw6tcd/LZXh\n-----END PRIVATE KEY-----' 23 | ); 24 | const TEST_IWA_BASE_URL = 25 | 'isolated-app://4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic/'; 26 | 27 | test('headerOverride - IWA with bad headers', async (t) => { 28 | const badHeadersTestCase = [ 29 | { 'cross-origin-embedder-policy': 'unsafe-none' }, 30 | { 'cross-origin-opener-policy': 'unsafe-none' }, 31 | { 'cross-origin-resource-policy': 'cross-origin' }, 32 | ]; 33 | 34 | for (const badHeaders of badHeadersTestCase) { 35 | for (const isIwaTestCase of [undefined, true]) { 36 | await t.throwsAsync( 37 | async () => { 38 | await getValidatedOptionsWithDefaults({ 39 | baseURL: TEST_IWA_BASE_URL, 40 | output: 'example.swbn', 41 | integrityBlockSign: { 42 | key: TEST_ED25519_PRIVATE_KEY, 43 | isIwa: isIwaTestCase, 44 | }, 45 | headerOverride: badHeaders, 46 | }); 47 | }, 48 | { instanceOf: Error } 49 | ); 50 | } 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "noEmit": false 6 | }, 7 | "include": ["**/*"], 8 | "exclude": ["test/**"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/shared/types.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { KeyObject } from 'crypto'; 18 | import { URL } from 'url'; 19 | import * as z from 'zod'; 20 | // TODO(sonkkeli: b/282899095): This should get fixed whenever we use a more 21 | // modern test framework like Jest. 22 | import { checkAndAddIwaHeaders, iwaHeaderDefaults } from './iwa-headers.js'; 23 | import { 24 | NodeCryptoSigningStrategy, 25 | ISigningStrategy, 26 | WebBundleId, 27 | } from 'wbn-sign'; 28 | 29 | const headersSchema = z.record(z.string()); 30 | 31 | export type Headers = z.infer; 32 | 33 | const baseOptionsSchema = z.strictObject({ 34 | static: z 35 | .strictObject({ 36 | dir: z.string(), 37 | baseURL: z.string().optional(), 38 | }) 39 | .optional(), 40 | baseURL: z.string().default(''), 41 | output: z.string().default('out.wbn'), 42 | formatVersion: z.enum(['b1', 'b2']).default('b2'), 43 | headerOverride: z 44 | .union([z.function().returns(headersSchema), headersSchema]) 45 | .optional(), 46 | }); 47 | 48 | const nonSigningSchema = baseOptionsSchema.extend({ 49 | primaryURL: z.string().optional(), 50 | }); 51 | 52 | const baseIntegrityBlockSignSchema = z.strictObject({ 53 | isIwa: z.boolean().default(true), 54 | }); 55 | 56 | const keyBasedIntegrityBlockSignSchema = baseIntegrityBlockSignSchema 57 | .extend({ 58 | // Unfortunately we cannot use `KeyObject` directly within `instanceof()`, 59 | // because its constructor is private. 60 | key: z 61 | .instanceof(Object) 62 | .refine((key): key is KeyObject => key instanceof KeyObject, { 63 | message: `Key must be an instance of "KeyObject"`, 64 | }), 65 | }) 66 | 67 | // Use the default NodeCryptoSigningStrategy strategy instead of key. 68 | .transform((ibSignOpts) => { 69 | return { 70 | isIwa: ibSignOpts.isIwa, 71 | webBundleId: new WebBundleId(ibSignOpts.key).serialize(), 72 | strategies: [new NodeCryptoSigningStrategy(ibSignOpts.key)], 73 | }; 74 | }); 75 | 76 | const strategyBasedIntegrityBlockSignSchema = baseIntegrityBlockSignSchema 77 | .extend({ 78 | strategy: z.instanceof(Object).refine( 79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 | (strategy: Record): strategy is ISigningStrategy => { 81 | return ['getPublicKey', 'sign'].every( 82 | (func) => func in strategy && typeof strategy[func] === 'function' 83 | ); 84 | }, 85 | { message: `Strategy must implement "ISigningStrategy"` } 86 | ), 87 | }) 88 | .transform(async (ibSignOpts) => { 89 | return { 90 | isIwa: ibSignOpts.isIwa, 91 | webBundleId: new WebBundleId( 92 | await ibSignOpts.strategy.getPublicKey() 93 | ).serialize(), 94 | strategies: [ibSignOpts.strategy], 95 | }; 96 | }); 97 | 98 | const strategiesBasedIntegrityBlockSignSchemaWithWebBundleId = 99 | baseIntegrityBlockSignSchema.extend({ 100 | strategies: z 101 | .array( 102 | z.instanceof(Object).refine( 103 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 104 | (strategy: Record): strategy is ISigningStrategy => { 105 | return ['getPublicKey', 'sign'].every( 106 | (func) => func in strategy && typeof strategy[func] === 'function' 107 | ); 108 | }, 109 | { message: `Strategy must implement "ISigningStrategy"` } 110 | ) 111 | ) 112 | .min(1), 113 | webBundleId: z.string().min(1), 114 | }); 115 | 116 | const signingSchema = baseOptionsSchema 117 | .extend({ 118 | integrityBlockSign: z.union([ 119 | keyBasedIntegrityBlockSignSchema, 120 | strategyBasedIntegrityBlockSignSchema, 121 | strategiesBasedIntegrityBlockSignSchemaWithWebBundleId, 122 | ]), 123 | }) 124 | 125 | // Check that `baseURL` is either not set, or set to the expected origin based 126 | // on the private key. 127 | .superRefine((opts, ctx) => { 128 | const webBundleId = opts.integrityBlockSign.webBundleId; 129 | if (opts.baseURL !== '' && new URL(opts.baseURL).host !== webBundleId) { 130 | ctx.addIssue({ 131 | code: z.ZodIssueCode.custom, 132 | message: `The hostname of the provided "baseURL" option (${opts.baseURL}) does not match the expected host (${webBundleId}) derived from the public key.`, 133 | }); 134 | } 135 | }) 136 | 137 | // Set and validate the `headerOverride` option. 138 | .transform((opts, ctx) => { 139 | if (!opts.integrityBlockSign.isIwa) { 140 | return opts; 141 | } 142 | 143 | if (opts.headerOverride === undefined) { 144 | console.info( 145 | `Setting the empty headerOverrides to IWA defaults. To bundle a non-IWA, set \`integrityBlockSign { isIwa: false }\` in your plugin configs. Defaults are set to:\n ${JSON.stringify( 146 | iwaHeaderDefaults 147 | )}` 148 | ); 149 | opts.headerOverride = iwaHeaderDefaults; 150 | } else if (typeof opts.headerOverride === 'object') { 151 | try { 152 | checkAndAddIwaHeaders(opts.headerOverride); 153 | } catch (err) { 154 | ctx.addIssue({ 155 | code: z.ZodIssueCode.custom, 156 | message: String(err), 157 | }); 158 | } 159 | } 160 | return opts; 161 | }); 162 | 163 | export const optionsSchema = z.union([nonSigningSchema, signingSchema]); 164 | 165 | export const getValidatedOptionsWithDefaults = optionsSchema.parseAsync; 166 | 167 | export type PluginOptions = z.input; 168 | 169 | export type ValidPluginOptions = z.infer; 170 | 171 | export type ValidIbSignPluginOptions = z.infer; 172 | -------------------------------------------------------------------------------- /packages/shared/utils.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as fs from 'fs'; 18 | import * as path from 'path'; 19 | import mime from 'mime'; 20 | import { combineHeadersForUrl, BundleBuilder } from 'wbn'; 21 | import { IntegrityBlockSigner } from 'wbn-sign'; 22 | import { checkAndAddIwaHeaders } from './iwa-headers'; 23 | import { ValidIbSignPluginOptions, ValidPluginOptions } from './types'; 24 | 25 | // If the file name is 'index.html', create an entry for both baseURL/dir/ and 26 | // baseURL/dir/index.html which redirects to the aforementioned. Otherwise just 27 | // for the asset itself. This matches the behavior of gen-bundle. 28 | export function addAsset( 29 | builder: BundleBuilder, 30 | baseURL: string, 31 | relativeAssetPath: string, // Asset's path relative to app's base dir. E.g. sub-dir/helloworld.js 32 | assetContentBuffer: Uint8Array | string, 33 | pluginOptions: ValidPluginOptions 34 | ) { 35 | const parsedAssetPath = path.parse(relativeAssetPath); 36 | const isIndexHtmlFile = parsedAssetPath.base === 'index.html'; 37 | 38 | // For object type, the IWA headers have already been check in constructor. 39 | const shouldCheckIwaHeaders = 40 | typeof pluginOptions.headerOverride === 'function' && 41 | 'integrityBlockSign' in pluginOptions && 42 | pluginOptions.integrityBlockSign.isIwa; 43 | 44 | if (isIndexHtmlFile) { 45 | const combinedIndexHeaders = combineHeadersForUrl( 46 | { Location: './' }, 47 | pluginOptions.headerOverride, 48 | baseURL + relativeAssetPath 49 | ); 50 | if (shouldCheckIwaHeaders) checkAndAddIwaHeaders(combinedIndexHeaders); 51 | 52 | builder.addExchange( 53 | baseURL + relativeAssetPath, 54 | 301, 55 | combinedIndexHeaders, 56 | '' // Empty content. 57 | ); 58 | } 59 | 60 | const baseURLWithAssetPath = 61 | baseURL + (isIndexHtmlFile ? parsedAssetPath.dir : relativeAssetPath); 62 | const combinedHeaders = combineHeadersForUrl( 63 | { 64 | 'Content-Type': 65 | mime.getType(relativeAssetPath) || 'application/octet-stream', 66 | }, 67 | pluginOptions.headerOverride, 68 | baseURLWithAssetPath 69 | ); 70 | if (shouldCheckIwaHeaders) checkAndAddIwaHeaders(combinedHeaders); 71 | 72 | builder.addExchange( 73 | baseURLWithAssetPath, 74 | 200, 75 | combinedHeaders, 76 | assetContentBuffer 77 | ); 78 | } 79 | 80 | export function addFilesRecursively( 81 | builder: BundleBuilder, 82 | baseURL: string, 83 | dir: string, 84 | pluginOptions: ValidPluginOptions, 85 | recPath = '' 86 | ) { 87 | const files = fs.readdirSync(dir); 88 | files.sort(); // Sort entries for reproducibility. 89 | 90 | for (const fileName of files) { 91 | const filePath = path.join(dir, fileName); 92 | 93 | if (fs.statSync(filePath).isDirectory()) { 94 | addFilesRecursively( 95 | builder, 96 | baseURL, 97 | filePath, 98 | pluginOptions, 99 | recPath + fileName + '/' 100 | ); 101 | } else { 102 | const fileContent = fs.readFileSync(filePath); 103 | // `fileName` contains the directory as this is done recursively for every 104 | // directory so it gets added to the baseURL. 105 | addAsset( 106 | builder, 107 | baseURL, 108 | recPath + fileName, 109 | fileContent, 110 | pluginOptions 111 | ); 112 | } 113 | } 114 | } 115 | 116 | export async function getSignedWebBundle( 117 | webBundle: Uint8Array, 118 | opts: ValidIbSignPluginOptions, 119 | infoLogger: (str: string) => void 120 | ): Promise { 121 | const { signedWebBundle } = await new IntegrityBlockSigner( 122 | /*is_v2=*/ true, 123 | webBundle, 124 | opts.integrityBlockSign.webBundleId, 125 | opts.integrityBlockSign.strategies 126 | ).sign(); 127 | 128 | infoLogger(opts.integrityBlockSign.webBundleId); 129 | return signedWebBundle; 130 | } 131 | -------------------------------------------------------------------------------- /packages/shared/wbn-types.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // These must match with the types in `wbn`. 18 | // github.com/WICG/webpackage/blob/main/js/bundle/src/constants.ts 19 | const B1 = 'b1'; 20 | const B2 = 'b2'; 21 | const APPROVED_VERSIONS = [B1, B2] as const; 22 | export type FormatVersion = typeof APPROVED_VERSIONS[number]; 23 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/README.md: -------------------------------------------------------------------------------- 1 | # webbundle-webpack-plugin 2 | 3 | A Webpack plugin which generates 4 | [Web Bundles](https://wicg.github.io/webpackage/draft-yasskin-wpack-bundled-exchanges.html) 5 | output. Currently the spec is still a draft, so this package is also in alpha 6 | until the spec stabilizes. 7 | 8 | ## Requirements 9 | 10 | This plugin requires Node v14.0.0+ and Webpack v4.0.1+. 11 | 12 | ## Install 13 | 14 | Using npm: 15 | 16 | ```bash 17 | npm install webbundle-webpack-plugin --save-dev 18 | ``` 19 | 20 | ## Usage 21 | 22 | ### General Web Bundle 23 | 24 | This example assumes your application entry point is `src/index.js` and static 25 | files (including `index.html`) are located in `static` directory. 26 | 27 | ```js 28 | /* webpack.config.js */ 29 | const path = require('path'); 30 | const WebBundlePlugin = require('webbundle-webpack-plugin'); 31 | 32 | module.exports = { 33 | entry: './src/index.js', 34 | output: { 35 | path: path.resolve(__dirname, 'dist'), 36 | filename: 'app.js', 37 | }, 38 | plugins: [ 39 | new WebBundlePlugin({ 40 | baseURL: 'https://example.com/', 41 | static: { dir: path.resolve(__dirname, 'static') }, 42 | output: 'example.wbn', 43 | }), 44 | ], 45 | }; 46 | ``` 47 | 48 | A WBN file `dist/example.wbn` should be written. 49 | 50 | ### [Isolated Web App](https://github.com/WICG/isolated-web-apps/blob/main/README.md) (Signed Web Bundle) 51 | 52 | This example assumes your application entry point is `src/index.js`, static 53 | files (including `index.html`) are located in `static` directory and you have a 54 | `.env` file in the root directory with `ED25519KEY` defined in it. The example 55 | also requires installing `dotenv` npm package as a dev dependency. 56 | 57 | It is also required to have a 58 | [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) at 59 | `/.well-known/manifest.webmanifest`, which can be placed e.g. in the `static` 60 | directory. 61 | 62 | Also as in the below example, `baseURL` must be of format 63 | `isolated-app://${WEB_BUNDLE_ID}` for Isolated Web Apps. It can easily be 64 | generated from the private key with `WebBundleId` helper class from `wbn-sign` 65 | package. See 66 | [Scheme explainer](https://github.com/WICG/isolated-web-apps/blob/main/Scheme.md) 67 | for more details. Also note that providing `headerOverride` is optional. 68 | 69 | ```js 70 | /* webpack.config.js */ 71 | const path = require('path'); 72 | const WebBundlePlugin = require('webbundle-webpack-plugin'); 73 | const { 74 | NodeCryptoSigningStrategy, 75 | parsePemKey, 76 | readPassphrase, 77 | WebBundleId, 78 | } = require('wbn-sign'); 79 | require('dotenv').config({ path: './.env' }); 80 | 81 | module.exports = async () => { 82 | const key = parsePemKey(process.env.ENC_ED25519KEY, await readPassphrase()); 83 | 84 | return { 85 | entry: './src/index.js', 86 | output: { path: path.resolve(__dirname, 'dist'), filename: 'app.js' }, 87 | plugins: [ 88 | new WebBundlePlugin({ 89 | baseURL: new WebBundleId(key).serializeWithIsolatedWebAppOrigin(), 90 | static: { dir: path.resolve(__dirname, 'static') }, 91 | output: 'signed.swbn', 92 | integrityBlockSign: { 93 | strategy: new NodeCryptoSigningStrategy(key), 94 | }, 95 | headerOverride: { 96 | 'cross-origin-embedder-policy': 'require-corp', 97 | 'cross-origin-opener-policy': 'same-origin', 98 | 'cross-origin-resource-policy': 'same-origin', 99 | 'content-security-policy': 100 | "base-uri 'none'; default-src 'self'; object-src 'none'; frame-src 'self' https: blob: data:; connect-src 'self' https: wss:; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' https: blob: data:; media-src 'self' https: blob: data:; font-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; require-trusted-types-for 'script';", 101 | }, 102 | }), 103 | ], 104 | }; 105 | }; 106 | ``` 107 | 108 | A signed web bundle (containing an 109 | [Integrity Block](https://github.com/WICG/webpackage/blob/main/explainers/integrity-signature.md)) 110 | should be written to `dist/signed.swbn`. 111 | 112 | ## Options 113 | 114 | ### `baseURL` 115 | 116 | Type: `string` 117 | 118 | Default: `''` 119 | 120 | Specifies the URL prefix prepended to the file names in the bundle. Non-empty 121 | baseURL must end with `/`. 122 | 123 | ### `primaryURL` 124 | 125 | Type: `string` 126 | 127 | Default: baseURL 128 | 129 | Specifies the bundle's main resource URL. 130 | 131 | ### `static` 132 | 133 | Type: `{ dir: String, baseURL?: string }` 134 | 135 | If specified, files and subdirectories under `dir` will be added to the bundle. 136 | The `baseURL` field can be omitted and defaults to `Options.baseURL`. 137 | 138 | ### `output` 139 | 140 | Type: `string` 141 | 142 | Default: `out.wbn` 143 | 144 | Specifies the file name of the Web Bundle to emit. 145 | 146 | ### `formatVersion` 147 | 148 | Type: `string` 149 | 150 | Default: `b2` 151 | 152 | Specifies WebBundle format version. 153 | 154 | - version `b2` follows 155 | [the latest version of the Web Bundles spec](https://datatracker.ietf.org/doc/html/draft-yasskin-wpack-bundled-exchanges-04) 156 | (default). 157 | - version `b1` follows 158 | [the previous version of the Web Bundles spec](https://datatracker.ietf.org/doc/html/draft-yasskin-wpack-bundled-exchanges-03). 159 | 160 | ### `integrityBlockSign` 161 | 162 | Type: 163 | 164 | - `{ key: KeyObject, isIwa?: boolean }` 165 | - `{ strategy: ISigningStrategy, isIwa?: boolean }` 166 | - `{ strategies: Array, webBundleId: string, isIwa?: boolean }` 167 | 168 | Object specifying the signing options with 169 | [Integrity Block](https://github.com/WICG/webpackage/blob/main/explainers/integrity-signature.md). 170 | 171 | ### `integrityBlockSign.key` 172 | 173 | Note: Either this or `integrityBlockSign.strategy` is required when 174 | `integrityBlockSign` is in place. 175 | 176 | Type: `KeyObject` 177 | 178 | An unencrypted ed25519 private key can be generated with: 179 | 180 | ```bash 181 | openssl genpkey -algorithm Ed25519 -out ed25519key.pem 182 | ``` 183 | 184 | For better security, one should prefer using passphrase-encrypted ed25519 185 | private keys. To encrypt an unencrypted private key, run: 186 | 187 | ```bash 188 | # encrypt the key (will ask for a passphrase, make sure to use a strong one) 189 | openssl pkcs8 -in ed25519key.pem -topk8 -out encrypted_ed25519key.pem 190 | 191 | # delete the unencrypted key 192 | rm ed25519key.pem 193 | ``` 194 | 195 | It can be parsed with an imported helper function `parsePemKey(...)` from 196 | `wbn-sign` npm package. For an encrypted private key there's also an async 197 | helper function (`readPassphrase()`) to prompt the user for the passphrase the 198 | key was encrypted with. 199 | 200 | ```js 201 | // For an unencrypted ed25519 key. 202 | const key = parsePemKey(process.env.ED25519KEY); 203 | 204 | // For an encrypted ed25519 key. 205 | const key = parsePemKey(process.env.ENC_ED25519KEY, await readPassphrase()); 206 | ``` 207 | 208 | Note that in order for the key to be parsed correctly, it must contain the 209 | `BEGIN` and `END` headers and line breaks (`\n`). Below an example `.env` file: 210 | 211 | ```bash 212 | ED25519KEY="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIB8nP5PpWU7HiILHSfh5PYzb5GAcIfHZ+bw6tcd/LZXh\n-----END PRIVATE KEY-----" 213 | ``` 214 | 215 | ### `integrityBlockSign.strategy` 216 | 217 | Note: Either this or `integrityBlockSign.key` is required when 218 | `integrityBlockSign` is in place. 219 | 220 | Type: `ISigningStrategy` 221 | 222 | Example web bundle plugin options using a signing strategy: 223 | 224 | ```js 225 | const pluginOptionsWithPredefinedSigningStrategy = { 226 | // ...other plugin options here... 227 | integrityBlockSign: { 228 | strategy: new NodeCryptoSigningStrategy(privateKey), 229 | }, 230 | }; 231 | 232 | const pluginOptionsWithCustomSigningStrategy = { 233 | // ...other plugin options here... 234 | integrityBlockSign: { 235 | strategy: new (class /* implements ISigningStrategy */ { 236 | async sign(data) { 237 | /** E.g. connect to one's external signing service that signs the 238 | * payload. */ 239 | } 240 | async getPublicKey() { 241 | /** E.g. connect to one's external signing service that returns the 242 | * public key. */ 243 | } 244 | })(), 245 | }, 246 | }; 247 | ``` 248 | 249 | ### `integrityBlockSign.strategies` 250 | 251 | Type: `Array` 252 | 253 | Use this overload to sign a bundle with multiple keys. Note that `webBundleId` 254 | must always be specified when using `strategies`. 255 | 256 | ``` 257 | const pluginOptionsWithMultipleStrategiesAndWebBundleId = { 258 | // ...other plugin options here... 259 | integrityBlockSign: { 260 | strategies: [ 261 | new NodeCryptoSigningStrategy(privateKey1), 262 | new NodeCryptoSigningStrategy(privateKey2) 263 | ], 264 | webBundleId: "some-random-id" 265 | }, 266 | }; 267 | ``` 268 | 269 | ### `integrityBlockSign.webBundleId` 270 | 271 | Type: `string` 272 | 273 | Allows specifying a custom id for this signed web bundle to decouple it from the 274 | signing keys. Must be used together with `strategies`. 275 | 276 | ### `integrityBlockSign.isIwa` (optional) 277 | 278 | Type: `boolean` 279 | 280 | Default: `true` 281 | 282 | If `undefined` or `true`, enforces certain 283 | [Isolated Web App](https://github.com/WICG/isolated-web-apps) -related checks 284 | for the headers. Also adds default IWA headers if completely missing. If set to 285 | `false`, skips validation checks and doesn't tamper with the headers. 286 | 287 | ### `headerOverride` (optional) 288 | 289 | Type: `{ [key: string]: string; }` | 290 | `(filepath: string) => { [key: string]: string; };` 291 | 292 | Object of strings specifying overridden headers or a function returning the same 293 | kind of object. 294 | 295 | ## Discuss & Help 296 | 297 | For discussions related to this repository's content, the Web Bundle plugins for 298 | webpack and rollup, please use 299 | [GitHub Issues](https://github.com/GoogleChromeLabs/webbundle-plugins/issues). 300 | 301 | If you'd like to discuss the Web Packaging proposal itself, consider opening an 302 | issue in its incubation repository at https://github.com/WICG/webpackage. 303 | 304 | For discussions related to Isolated Web Apps in general, or Chromium-specific 305 | implementation and development questions, please use the 306 | [iwa-dev@chromium.org](https://groups.google.com/a/chromium.org/g/iwa-dev) 307 | mailing list. 308 | 309 | If you'd like to discuss the Isolated Web Apps proposal, which builds on top of 310 | Web Bundles, consider opening an issue in the incubation repository at 311 | https://github.com/WICG/isolated-web-apps. 312 | 313 | ## Release Notes 314 | 315 | ### v0.2.0 316 | 317 | - Add support for the v2 integrity block format. Now web-bundle-id is no longer 318 | presumed to be a derivative of the first public key in the stack, but rather 319 | acts as a separate entry in the integrity block attributes, and multiple 320 | independent signatures are allowed to facilitate key rotation. 321 | 322 | ### v0.1.5 323 | 324 | - Add support for ECDSA P-256 SHA-256 signing algorithm 325 | - Bumping underlying wbn-sign version to v0.1.3. 326 | 327 | ### v0.1.4 328 | 329 | - Updates to style-src and wss CSP values. 330 | - Bumping underlying wbn-sign version to v0.1.1. 331 | 332 | ### v0.1.3 333 | 334 | - BUG: Async `integrityBlockSign.strategy` was not working correctly. Now with 335 | `tapPromise` instead of `tap` this is fixed. 336 | \[[#59](https://github.com/GoogleChromeLabs/webbundle-plugins/pull/59/)\] 337 | 338 | ### v0.1.2 339 | 340 | - Add support for `integrityBlockSign.strategy` plugin option which can be used 341 | to pass one of the predefined strategies or one's own implementation class for 342 | ISigningStrategy. One can also use the old `integrityBlockSign.key` option, 343 | which defaults to the predefined `NodeCryptoSigningStrategy` strategy. 344 | - Refactor plugin to be in TypeScript. 345 | - Combine the Webpack and Rollup web bundle plugins to live in the same 346 | repository and share some duplicated code. Taking advantage of 347 | [npm workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces). 348 | 349 | ### v0.1.1 350 | 351 | - Add support for overriding headers with an optional `headerOverride` plugin 352 | option. 353 | 354 | ### v0.1.0 355 | 356 | - BREAKING CHANGE: Change type of integrityBlockSign.key to be KeyObject instead 357 | of string. 358 | 359 | ### v0.0.4 360 | 361 | - Support for signing web bundles with 362 | [integrity block](https://github.com/WICG/webpackage/blob/main/explainers/integrity-signature.md) 363 | added. 364 | 365 | ## License 366 | 367 | Licensed under the Apache-2.0 license. 368 | 369 | ## Contributing 370 | 371 | See [CONTRIBUTING.md](../../CONTRIBUTING.md) file. 372 | 373 | ## Disclaimer 374 | 375 | This is not an officially supported Google product. 376 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/index.cjs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2023 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // This is just a CommonJS wrapper for the default export to not to introduce a 18 | // breaking change on how WebBundlePlugin is imported. 19 | // For context, see: 20 | // https://github.com/evanw/esbuild/issues/532#issuecomment-1019392638 21 | // TODO: Get rid of this together with the next other breaking change. 22 | 23 | // eslint-disable-next-line @typescript-eslint/no-var-requires 24 | const { WebBundlePlugin } = require('./lib/index.cjs'); 25 | module.exports = WebBundlePlugin; 26 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webbundle-webpack-plugin", 3 | "version": "0.2.0", 4 | "description": "Webpack plugin to generate WebBundle output.", 5 | "keywords": [ 6 | "webpack", 7 | "plugin", 8 | "web-bundle", 9 | "isolated-web-app" 10 | ], 11 | "type": "module", 12 | "main": "index.cjs", 13 | "scripts": { 14 | "prepack": "npm run build && cp ../../LICENSE ./LICENSE", 15 | "postpack": "rm -f ./LICENSE", 16 | "build": "rm -rf lib && esbuild --bundle --packages=external --format=cjs --outfile=lib/index.cjs src/index.ts --platform=node --legal-comments=inline --sourcemap --keep-names" 17 | }, 18 | "author": "Kunihiko Sakamoto ", 19 | "contributors": [ 20 | "Sonja Laurila (https://github.com/sonkkeli)", 21 | "Christian Flach (https://github.com/cmfcmf)", 22 | "Andrew Rayskiy (https://github.com/GreenGrape)" 23 | ], 24 | "license": "Apache-2.0", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/GoogleChromeLabs/webbundle-plugins.git", 28 | "directory": "packages/webbundle-webpack-plugin" 29 | }, 30 | "peerDependencies": { 31 | "webpack": ">=4.0.1 <6.0.0" 32 | }, 33 | "dependencies": { 34 | "mime": "^2.4.4", 35 | "wbn": "0.0.9", 36 | "wbn-sign": "0.2.0", 37 | "zod": "^3.21.4" 38 | }, 39 | "devDependencies": { 40 | "esbuild": "^0.17.15", 41 | "memory-fs": "^0.5.0", 42 | "webpack": "^5.94.0", 43 | "@types/webpack": "*" 44 | }, 45 | "engines": { 46 | "node": ">= 16.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import webpack, { Compiler, Compilation, WebpackPluginInstance } from 'webpack'; 18 | import { BundleBuilder } from 'wbn'; 19 | import { 20 | addAsset, 21 | addFilesRecursively, 22 | getSignedWebBundle, 23 | } from '../../shared/utils'; 24 | import { 25 | getValidatedOptionsWithDefaults, 26 | PluginOptions, 27 | } from '../../shared/types'; 28 | 29 | const PLUGIN_NAME = 'webbundle-webpack-plugin'; 30 | 31 | // Returns if the semantic version number of Webpack is 4. 32 | function isWebpackMajorV4(): boolean { 33 | return webpack.version.startsWith('4.'); 34 | } 35 | 36 | export class WebBundlePlugin implements WebpackPluginInstance { 37 | constructor(private rawOpts: PluginOptions) {} 38 | 39 | process = async (compilation: Compilation) => { 40 | const opts = await getValidatedOptionsWithDefaults(this.rawOpts); 41 | 42 | const builder = new BundleBuilder(opts.formatVersion); 43 | if ('primaryURL' in opts && opts.primaryURL) { 44 | builder.setPrimaryURL(opts.primaryURL); 45 | } 46 | if (opts.static) { 47 | addFilesRecursively( 48 | builder, 49 | opts.static.baseURL ?? opts.baseURL, 50 | opts.static.dir, 51 | opts 52 | ); 53 | } 54 | 55 | for (const [assetName, assetValue] of Object.entries(compilation.assets)) { 56 | const assetRawSource = assetValue.source(); 57 | const assetBuffer = Buffer.isBuffer(assetRawSource) 58 | ? assetRawSource 59 | : Buffer.from(assetRawSource); 60 | 61 | addAsset( 62 | builder, 63 | opts.baseURL, 64 | assetName, // This contains the relative path to the base dir already. 65 | assetBuffer, 66 | opts 67 | ); 68 | } 69 | 70 | // TODO: Logger is supported v4.37+. Remove once Webpack versions below that 71 | // are no longer supported. 72 | const infoLogger = 73 | typeof compilation.getLogger === 'function' 74 | ? (str: string) => compilation.getLogger(PLUGIN_NAME).info(str) 75 | : (str: string) => console.log(str); 76 | 77 | let webBundle = builder.createBundle(); 78 | if ('integrityBlockSign' in opts) { 79 | webBundle = await getSignedWebBundle(webBundle, opts, infoLogger); 80 | } 81 | 82 | if (isWebpackMajorV4()) { 83 | // @ts-expect-error Missing properties don't exist on webpack v4. 84 | compilation.assets[opts.output] = { 85 | source: () => Buffer.from(webBundle), 86 | size: () => webBundle.length, 87 | }; 88 | } else { 89 | compilation.assets[opts.output] = new webpack.sources.RawSource( 90 | Buffer.from(webBundle), 91 | /*convertToString=*/ false 92 | ); 93 | } 94 | }; 95 | 96 | apply = (compiler: Compiler) => { 97 | if (isWebpackMajorV4()) { 98 | compiler.hooks.emit.tapPromise( 99 | this.constructor.name, 100 | (compilation: Compilation) => this.process(compilation) 101 | ); 102 | } else { 103 | compiler.hooks.thisCompilation.tap( 104 | this.constructor.name, 105 | (compilation: Compilation) => { 106 | compilation.hooks.processAssets.tapPromise( 107 | { 108 | name: this.constructor.name, 109 | stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, 110 | }, 111 | () => this.process(compilation) 112 | ); 113 | } 114 | ); 115 | } 116 | }; 117 | } 118 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/test/fixtures/app.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | console.log('Hello, world!'); 18 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/test/fixtures/static/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/webbundle-webpack-plugin/test/test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'ava'; 18 | import crypto from 'crypto'; 19 | import webpack from 'webpack'; 20 | import MemoryFS from 'memory-fs'; 21 | import url from 'url'; 22 | import fs from 'fs'; 23 | import * as path from 'path'; 24 | import * as wbn from 'wbn'; 25 | import * as wbnSign from 'wbn-sign'; 26 | import { WebBundlePlugin } from '../lib/index.cjs'; 27 | import { 28 | coep, 29 | coop, 30 | corp, 31 | csp, 32 | iwaHeaderDefaults, 33 | } from '../../shared/lib/iwa-headers.js'; 34 | 35 | const __filename = url.fileURLToPath(import.meta.url); 36 | const __dirname = path.dirname(__filename); 37 | 38 | const TEST_ED25519_PRIVATE_KEY = wbnSign.parsePemKey( 39 | '-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIB8nP5PpWU7HiILHSfh5PYzb5GAcIfHZ+bw6tcd/LZXh\n-----END PRIVATE KEY-----' 40 | ); 41 | const TEST_IWA_BASE_URL = 42 | 'isolated-app://4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic/'; 43 | 44 | function run(options) { 45 | return new Promise((resolve, reject) => { 46 | const compiler = webpack({ 47 | mode: 'development', 48 | devtool: false, 49 | entry: path.join(__dirname, 'fixtures', 'app'), 50 | bail: true, 51 | output: { 52 | path: '/out', 53 | filename: 'main.js', 54 | }, 55 | plugins: [new WebBundlePlugin(options)], 56 | }); 57 | const memfs = new MemoryFS(); 58 | compiler.outputFileSystem = memfs; 59 | compiler.run((err, stats) => { 60 | err ? reject(err) : resolve({ stats, memfs }); 61 | }); 62 | }); 63 | } 64 | 65 | test('basic', async (t) => { 66 | const primaryURL = 'https://example.com/main.js'; 67 | const { memfs } = await run({ 68 | baseURL: 'https://example.com/', 69 | primaryURL, 70 | output: 'example.wbn', 71 | }); 72 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.wbn', 'main.js']); 73 | const js = memfs.readFileSync('/out/main.js'); 74 | const bundle = new wbn.Bundle(memfs.readFileSync('/out/example.wbn')); 75 | t.is(bundle.primaryURL, primaryURL); 76 | t.deepEqual(bundle.urls, [primaryURL]); 77 | const resp = bundle.getResponse(primaryURL); 78 | t.is(new TextDecoder('utf-8').decode(resp.body), js.toString()); 79 | }); 80 | 81 | test('static', async (t) => { 82 | const primaryURL = 'https://example.com/'; 83 | const { memfs } = await run({ 84 | baseURL: 'https://example.com/', 85 | primaryURL, 86 | static: { dir: path.join(__dirname, 'fixtures', 'static') }, 87 | output: 'example.wbn', 88 | }); 89 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.wbn', 'main.js']); 90 | const html = fs.readFileSync( 91 | path.join(__dirname, 'fixtures', 'static', 'index.html') 92 | ); 93 | const bundle = new wbn.Bundle(memfs.readFileSync('/out/example.wbn')); 94 | t.is(bundle.primaryURL, primaryURL); 95 | t.deepEqual(bundle.urls.sort(), [ 96 | primaryURL, 97 | 'https://example.com/index.html', 98 | 'https://example.com/main.js', 99 | ]); 100 | const resp = bundle.getResponse(primaryURL); 101 | t.is(new TextDecoder('utf-8').decode(resp.body), html.toString()); 102 | }); 103 | 104 | test('relative', async (t) => { 105 | const { memfs } = await run({ 106 | static: { dir: path.join(__dirname, 'fixtures', 'static') }, 107 | output: 'example.wbn', 108 | }); 109 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.wbn', 'main.js']); 110 | const html = fs.readFileSync( 111 | path.join(__dirname, 'fixtures', 'static', 'index.html') 112 | ); 113 | const js = memfs.readFileSync('/out/main.js'); 114 | const bundle = new wbn.Bundle(memfs.readFileSync('/out/example.wbn')); 115 | t.deepEqual(bundle.urls.sort(), ['', 'index.html', 'main.js']); 116 | let resp = bundle.getResponse(''); 117 | t.is(new TextDecoder('utf-8').decode(resp.body), html.toString()); 118 | resp = bundle.getResponse('main.js'); 119 | t.is(new TextDecoder('utf-8').decode(resp.body), js.toString()); 120 | }); 121 | 122 | test('integrityBlockSign', async (t) => { 123 | const testCases = [ 124 | // With default signer. 125 | { 126 | key: TEST_ED25519_PRIVATE_KEY, 127 | }, 128 | // With signer option specified. 129 | { 130 | strategy: new wbnSign.NodeCryptoSigningStrategy(TEST_ED25519_PRIVATE_KEY), 131 | }, 132 | ]; 133 | for (const testCase of testCases) { 134 | const { memfs } = await run({ 135 | baseURL: TEST_IWA_BASE_URL, 136 | output: 'example.swbn', 137 | integrityBlockSign: testCase, 138 | }); 139 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.swbn', 'main.js']); 140 | 141 | const swbnFile = memfs.readFileSync('/out/example.swbn'); 142 | const wbnLength = Number(Buffer.from(swbnFile.slice(-8)).readBigUint64BE()); 143 | t.truthy(wbnLength < swbnFile.length); 144 | 145 | const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner( 146 | /*is_v2=*/ true, 147 | swbnFile.slice(-wbnLength), 148 | new wbnSign.WebBundleId(TEST_ED25519_PRIVATE_KEY).serialize(), 149 | [new wbnSign.NodeCryptoSigningStrategy(TEST_ED25519_PRIVATE_KEY)] 150 | ).sign(); 151 | 152 | t.deepEqual(swbnFile, Buffer.from(signedWebBundle)); 153 | } 154 | }); 155 | 156 | test('headerOverride - IWA with good headers', async (t) => { 157 | const headersTestCases = [ 158 | // These are added manually as they expect more than just `iwaHeaderDefaults`. 159 | { 160 | headerOverride: { ...iwaHeaderDefaults, 'X-Csrf-Token': 'hello-world' }, 161 | expectedHeaders: { ...iwaHeaderDefaults, 'x-csrf-token': 'hello-world' }, 162 | }, 163 | { 164 | headerOverride: () => { 165 | return { ...iwaHeaderDefaults, 'X-Csrf-Token': 'hello-world' }; 166 | }, 167 | expectedHeaders: { ...iwaHeaderDefaults, 'x-csrf-token': 'hello-world' }, 168 | }, 169 | ]; 170 | 171 | const headersThatDefaultToIWADefaults = [ 172 | { ...coop, ...corp, ...csp }, 173 | { ...coep, ...corp, ...csp }, 174 | { ...coep, ...coop, ...csp }, 175 | { ...coep, ...coop, ...corp }, 176 | iwaHeaderDefaults, 177 | {}, 178 | undefined, 179 | { 180 | ...iwaHeaderDefaults, 181 | 'Cross-Origin-Embedder-Policy': 'require-corp', 182 | }, 183 | ]; 184 | 185 | for (const headers of headersThatDefaultToIWADefaults) { 186 | // Both functions and objects are ok so let's test with both. 187 | headersTestCases.push({ 188 | headerOverride: headers, 189 | expectedHeaders: iwaHeaderDefaults, 190 | }); 191 | 192 | // Not supported as typeof function because that's forced to return `Headers` map. 193 | if (headers === undefined) continue; 194 | headersTestCases.push({ 195 | headerOverride: () => headers, 196 | expectedHeaders: iwaHeaderDefaults, 197 | }); 198 | } 199 | 200 | for (const headersTestCase of headersTestCases) { 201 | for (const isIwaTestCase of [undefined, true]) { 202 | const { memfs } = await run({ 203 | baseURL: TEST_IWA_BASE_URL, 204 | output: 'example.swbn', 205 | integrityBlockSign: { 206 | key: TEST_ED25519_PRIVATE_KEY, 207 | isIwa: isIwaTestCase, 208 | }, 209 | headerOverride: headersTestCase.headerOverride, 210 | }); 211 | t.deepEqual(memfs.readdirSync('/out').sort(), [ 212 | 'example.swbn', 213 | 'main.js', 214 | ]); 215 | 216 | const swbnFile = memfs.readFileSync('/out/example.swbn'); 217 | const wbnLength = Number( 218 | Buffer.from(swbnFile.slice(-8)).readBigUint64BE() 219 | ); 220 | t.truthy(wbnLength < swbnFile.length); 221 | 222 | const usignedBundle = new wbn.Bundle(swbnFile.slice(-wbnLength)); 223 | for (const url of usignedBundle.urls) { 224 | for (const [headerName, headerValue] of Object.entries( 225 | iwaHeaderDefaults 226 | )) { 227 | t.is(usignedBundle.getResponse(url).headers[headerName], headerValue); 228 | } 229 | } 230 | } 231 | } 232 | }); 233 | 234 | test("headerOverride - non-IWA doesn't enforce IWA headers", async (t) => { 235 | // Irrelevant what this would contain. 236 | const randomNonIwaHeaders = { 'x-csrf-token': 'hello-world' }; 237 | 238 | const headersTestCases = [ 239 | { 240 | // Type `object` is ok. 241 | headerOverride: randomNonIwaHeaders, 242 | expectedHeaders: randomNonIwaHeaders, 243 | }, 244 | { 245 | // Same but camel case, which gets lower-cased. 246 | headerOverride: { 'X-Csrf-Token': 'hello-world' }, 247 | expectedHeaders: randomNonIwaHeaders, 248 | }, 249 | { 250 | // Type `function` is ok. 251 | headerOverride: () => randomNonIwaHeaders, 252 | expectedHeaders: randomNonIwaHeaders, 253 | }, 254 | { 255 | // When `integrityBlockSign.isIwa` is false and `headerOverride` is 256 | // `undefined`, nothing unusual gets added. 257 | headerOverride: undefined, 258 | expectedHeaders: {}, 259 | }, 260 | ]; 261 | 262 | for (const headersTestCase of headersTestCases) { 263 | const { memfs } = await run({ 264 | baseURL: TEST_IWA_BASE_URL, 265 | output: 'example.swbn', 266 | integrityBlockSign: { 267 | key: TEST_ED25519_PRIVATE_KEY, 268 | isIwa: false, 269 | }, 270 | headerOverride: headersTestCase.headerOverride, 271 | }); 272 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.swbn', 'main.js']); 273 | 274 | const swbnFile = memfs.readFileSync('/out/example.swbn'); 275 | const wbnLength = Number(Buffer.from(swbnFile.slice(-8)).readBigUint64BE()); 276 | t.truthy(wbnLength < swbnFile.length); 277 | 278 | const usignedBundle = new wbn.Bundle(swbnFile.slice(-wbnLength)); 279 | for (const url of usignedBundle.urls) { 280 | // Added the expected headers. 281 | for (const [headerName, headerValue] of Object.entries( 282 | headersTestCase.expectedHeaders 283 | )) { 284 | t.is(usignedBundle.getResponse(url).headers[headerName], headerValue); 285 | } 286 | // Did not add any IWA headers automatically. 287 | for (const headerName of Object.keys(iwaHeaderDefaults)) { 288 | t.is(usignedBundle.getResponse(url).headers[headerName], undefined); 289 | } 290 | } 291 | } 292 | }); 293 | 294 | test("integrityBlockSign with undefined baseURL doesn't fail", async (t) => { 295 | const { memfs } = await run({ 296 | output: 'example.swbn', 297 | integrityBlockSign: { 298 | strategy: new wbnSign.NodeCryptoSigningStrategy(TEST_ED25519_PRIVATE_KEY), 299 | }, 300 | }); 301 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.swbn', 'main.js']); 302 | }); 303 | 304 | // The webpack plugin had a bug that it didn't work for actually async code so 305 | // this test will prevent that from occurring again. 306 | test('integrityBlockSign with sleeping CustomSigningStrategy', async (t) => { 307 | function sleep(ms) { 308 | return new Promise((resolve) => { 309 | setTimeout(resolve, ms); 310 | }); 311 | } 312 | 313 | class CustomSigningStrategy { 314 | async sign(data) { 315 | await sleep(500); 316 | return crypto.sign( 317 | /*algorithm=*/ undefined, 318 | data, 319 | TEST_ED25519_PRIVATE_KEY 320 | ); 321 | } 322 | 323 | async getPublicKey() { 324 | await sleep(500); 325 | return crypto.createPublicKey(TEST_ED25519_PRIVATE_KEY); 326 | } 327 | } 328 | 329 | const { memfs } = await run({ 330 | output: 'async.swbn', 331 | integrityBlockSign: { 332 | strategy: new CustomSigningStrategy(), 333 | }, 334 | }); 335 | t.deepEqual(memfs.readdirSync('/out').sort(), ['async.swbn', 'main.js']); 336 | }); 337 | 338 | test('integrityBlockSign with multiple strategies', async (t) => { 339 | // ECDSA P-256 SHA-256 signatures are not deterministic, so this test uses 340 | // only Ed25519 ones for generating bundles side-by-side. 341 | const keyPairs = [ 342 | crypto.generateKeyPairSync('ed25519'), 343 | crypto.generateKeyPairSync('ed25519'), 344 | crypto.generateKeyPairSync('ed25519'), 345 | ]; 346 | 347 | const webBundleId = new wbnSign.WebBundleId(keyPairs[0].privateKey); 348 | const strategies = keyPairs.map( 349 | (keyPair) => new wbnSign.NodeCryptoSigningStrategy(keyPair.privateKey) 350 | ); 351 | 352 | const { memfs } = await run({ 353 | baseURL: webBundleId.serializeWithIsolatedWebAppOrigin(), 354 | output: 'example.swbn', 355 | integrityBlockSign: { 356 | webBundleId: webBundleId.serialize(), 357 | strategies, 358 | }, 359 | }); 360 | 361 | t.deepEqual(memfs.readdirSync('/out').sort(), ['example.swbn', 'main.js']); 362 | 363 | const swbnFile = memfs.readFileSync('/out/example.swbn'); 364 | const wbnLength = Number(Buffer.from(swbnFile.slice(-8)).readBigUint64BE()); 365 | t.truthy(wbnLength < swbnFile.length); 366 | 367 | const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner( 368 | /*is_v2=*/ true, 369 | swbnFile.slice(-wbnLength), 370 | webBundleId.serialize(), 371 | strategies 372 | ).sign(); 373 | 374 | t.deepEqual(swbnFile, Buffer.from(signedWebBundle)); 375 | }); 376 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2020"], 4 | "module": "es2020", 5 | "target": "es2020", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | "strict": true, 10 | "declaration": true, 11 | "newLine": "lf", 12 | "noImplicitReturns": true, 13 | "skipLibCheck": true, 14 | "allowJs": true, 15 | "noEmit": true 16 | }, 17 | "include": ["packages/**/*"], 18 | "exclude": ["**/node_modules", "**/lib"] 19 | } 20 | --------------------------------------------------------------------------------