├── .depcheckrc.json ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── build-lint-test.yml │ ├── create-release-pr.yml │ ├── main.yml │ ├── publish-docs.yml │ ├── publish-main-docs.yml │ ├── publish-rc-docs.yml │ ├── publish-release.yml │ └── security-code-scanner.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .yarn └── plugins │ └── @yarnpkg │ └── plugin-allow-scripts.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── scripts ├── build-test.sh ├── get.sh └── prepack.sh ├── src ├── BasePostMessageStream.test.ts ├── BasePostMessageStream.ts ├── index.test.ts ├── index.ts ├── node-process │ ├── ProcessMessageStream.ts │ ├── ProcessParentMessageStream.ts │ └── node-process.test.ts ├── node-thread │ ├── ThreadMessageStream.test.ts │ ├── ThreadMessageStream.ts │ ├── ThreadParentMessageStream.ts │ └── node-thread.test.ts ├── node.test.ts ├── node.ts ├── runtime │ ├── BrowserRuntimePostMessageStream.test.ts │ └── BrowserRuntimePostMessageStream.ts ├── utils.ts ├── vendor │ └── types │ │ └── browser.d.ts ├── web-worker │ ├── WebWorker.test.ts │ ├── WebWorkerParentPostMessageStream.ts │ └── WebWorkerPostMessageStream.ts └── window │ ├── WindowPostMessageStream.test.ts │ └── WindowPostMessageStream.ts ├── tsconfig.build.json ├── tsconfig.json ├── typedoc.json └── yarn.lock /.depcheckrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignores": [ 3 | "@lavamoat/allow-scripts", 4 | "@types/*", 5 | "browserify", 6 | "prettier-plugin-packagejson" 7 | ], 8 | "ignore-patterns": ["dist-test/**"] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | extends: ['@metamask/eslint-config'], 5 | 6 | rules: { 7 | 'jsdoc/newline-after-description': 'off', 8 | 'jsdoc/tag-lines': [ 9 | 'error', 10 | 'any', 11 | { 12 | startLines: 1, 13 | }, 14 | ], 15 | }, 16 | 17 | overrides: [ 18 | { 19 | files: ['*.ts'], 20 | extends: ['@metamask/eslint-config-typescript'], 21 | rules: { 22 | '@typescript-eslint/consistent-type-definitions': 'off', 23 | }, 24 | }, 25 | 26 | { 27 | files: ['*.js'], 28 | parserOptions: { 29 | sourceType: 'script', 30 | }, 31 | extends: ['@metamask/eslint-config-nodejs'], 32 | }, 33 | 34 | { 35 | files: ['*.test.ts', '*.test.js'], 36 | extends: ['@metamask/eslint-config-jest'], 37 | }, 38 | ], 39 | 40 | ignorePatterns: ['!.eslintrc.js', 'dist/', 'dist-test/', 'docs/'], 41 | }; 42 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | yarn.lock linguist-generated=false 4 | 5 | # yarn v3 6 | # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 7 | /.yarn/releases/** binary 8 | /.yarn/plugins/** binary 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | * @MetaMask/devs 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'daily' 10 | time: '06:00' 11 | allow: 12 | - dependency-name: '@metamask/*' 13 | target-branch: 'main' 14 | versioning-strategy: 'increase-if-necessary' 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/workflows/build-lint-test.yml: -------------------------------------------------------------------------------- 1 | name: Build, Lint, and Test 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | prepare: 8 | name: Prepare 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x, 22.x] 13 | steps: 14 | - name: Checkout and setup environment 15 | uses: MetaMask/action-checkout-and-setup@v1 16 | with: 17 | is-high-risk-environment: false 18 | node-version: ${{ matrix.node-version }} 19 | cache-node-modules: ${{ matrix.node-version == '22.x' }} 20 | 21 | build: 22 | name: Build 23 | needs: prepare 24 | runs-on: ubuntu-latest 25 | strategy: 26 | matrix: 27 | node-version: [22.x] 28 | steps: 29 | - name: Checkout and setup environment 30 | uses: MetaMask/action-checkout-and-setup@v1 31 | with: 32 | is-high-risk-environment: false 33 | node-version: ${{ matrix.node-version }} 34 | - run: yarn build 35 | - name: Cache build artifacts 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: build-${{ github.sha }} 39 | path: | 40 | ./dist 41 | ./.nvmrc 42 | retention-days: 3 43 | - name: Require clean working directory 44 | shell: bash 45 | run: | 46 | if ! git diff --exit-code; then 47 | echo "Working tree dirty at end of job" 48 | exit 1 49 | fi 50 | 51 | lint: 52 | name: Lint 53 | needs: prepare 54 | runs-on: ubuntu-latest 55 | strategy: 56 | matrix: 57 | node-version: [22.x] 58 | steps: 59 | - name: Checkout and setup environment 60 | uses: MetaMask/action-checkout-and-setup@v1 61 | with: 62 | is-high-risk-environment: false 63 | node-version: ${{ matrix.node-version }} 64 | - run: yarn lint 65 | - name: Validate RC changelog 66 | if: ${{ startsWith(github.head_ref, 'release/') }} 67 | run: yarn lint:changelog --rc 68 | - name: Validate changelog 69 | if: ${{ !startsWith(github.head_ref, 'release/') }} 70 | run: yarn lint:changelog 71 | - name: Require clean working directory 72 | shell: bash 73 | run: | 74 | if ! git diff --exit-code; then 75 | echo "Working tree dirty at end of job" 76 | exit 1 77 | fi 78 | 79 | test: 80 | name: Test 81 | needs: 82 | - prepare 83 | - build 84 | runs-on: ubuntu-latest 85 | strategy: 86 | matrix: 87 | node-version: [18.x, 20.x, 22.x] 88 | steps: 89 | - name: Checkout and setup environment 90 | uses: MetaMask/action-checkout-and-setup@v1 91 | with: 92 | is-high-risk-environment: false 93 | node-version: ${{ matrix.node-version }} 94 | - name: Restore build artifacts 95 | uses: actions/download-artifact@v4 96 | with: 97 | name: build-${{ github.sha }} 98 | - name: Build test dependencies 99 | run: yarn build:test 100 | - name: Fix Electron permissions 101 | run: sudo chown root ./node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox 102 | # We have to use xvfb due to electron 103 | # Ref: https://github.com/facebook-atom/jest-electron-runner/issues/47#issuecomment-508556407 104 | - name: Run tests 105 | run: xvfb-run -e /dev/stdout yarn test 106 | - name: Require clean working directory 107 | shell: bash 108 | run: | 109 | if ! git diff --exit-code; then 110 | echo "Working tree dirty at end of job" 111 | exit 1 112 | fi 113 | 114 | compatibility-test: 115 | name: Compatibility test 116 | needs: 117 | - prepare 118 | - build 119 | runs-on: ubuntu-latest 120 | strategy: 121 | matrix: 122 | node-version: [18.x, 20.x, 22.x] 123 | steps: 124 | - name: Checkout and setup environment 125 | uses: MetaMask/action-checkout-and-setup@v1 126 | with: 127 | is-high-risk-environment: false 128 | node-version: ${{ matrix.node-version }} 129 | - name: Install dependencies via Yarn 130 | run: rm yarn.lock && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn 131 | - name: Restore build artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | name: build-${{ github.sha }} 135 | - name: Build test dependencies 136 | run: yarn build:test 137 | - name: Fix Electron permissions 138 | run: sudo chown root ./node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox 139 | # We have to use xvfb due to electron 140 | # Ref: https://github.com/facebook-atom/jest-electron-runner/issues/47#issuecomment-508556407 141 | - name: Run tests 142 | run: xvfb-run -e /dev/stdout yarn test 143 | - name: Restore lockfile 144 | run: git restore yarn.lock 145 | - name: Require clean working directory 146 | shell: bash 147 | run: | 148 | if ! git diff --exit-code; then 149 | echo "Working tree dirty at end of job" 150 | exit 1 151 | fi 152 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Pull Request 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | base-branch: 7 | description: 'The base branch for git operations and the pull request.' 8 | default: 'main' 9 | required: true 10 | release-type: 11 | description: 'A SemVer version diff, i.e. major, minor, or patch. Mutually exclusive with "release-version".' 12 | required: false 13 | release-version: 14 | description: 'A specific version to bump to. Mutually exclusive with "release-type".' 15 | required: false 16 | 17 | jobs: 18 | create-release-pr: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | steps: 24 | - name: Checkout and setup environment 25 | uses: MetaMask/action-checkout-and-setup@v1 26 | with: 27 | is-high-risk-environment: true 28 | 29 | # This is to guarantee that the most recent tag is fetched. This can 30 | # be configured to a more reasonable value by consumers. 31 | fetch-depth: 0 32 | 33 | # We check out the specified branch, which will be used as the base 34 | # branch for all git operations and the release PR. 35 | ref: ${{ github.event.inputs.base-branch }} 36 | 37 | - uses: MetaMask/action-create-release-pr@v4 38 | with: 39 | release-type: ${{ github.event.inputs.release-type }} 40 | release-version: ${{ github.event.inputs.release-version }} 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | check-workflows: 10 | name: Check workflows 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout and setup environment 14 | uses: MetaMask/action-checkout-and-setup@v1 15 | with: 16 | is-high-risk-environment: false 17 | - name: Download actionlint 18 | id: download-actionlint 19 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/7fdc9630cc360ea1a469eed64ac6d78caeda1234/scripts/download-actionlint.bash) 1.6.23 20 | shell: bash 21 | - name: Check workflow files 22 | run: ${{ steps.download-actionlint.outputs.executable }} -color 23 | shell: bash 24 | 25 | analyse-code: 26 | name: Code scanner 27 | needs: check-workflows 28 | uses: ./.github/workflows/security-code-scanner.yml 29 | permissions: 30 | actions: read 31 | contents: read 32 | security-events: write 33 | secrets: 34 | SECURITY_SCAN_METRICS_TOKEN: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} 35 | APPSEC_BOT_SLACK_WEBHOOK: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} 36 | 37 | build-lint-test: 38 | name: Build, lint, and test 39 | uses: ./.github/workflows/build-lint-test.yml 40 | 41 | all-jobs-completed: 42 | name: All jobs completed 43 | runs-on: ubuntu-latest 44 | needs: 45 | - check-workflows 46 | - analyse-code 47 | - build-lint-test 48 | outputs: 49 | PASSED: ${{ steps.set-output.outputs.PASSED }} 50 | steps: 51 | - name: Set PASSED output 52 | id: set-output 53 | run: echo "PASSED=true" >> "$GITHUB_OUTPUT" 54 | 55 | all-jobs-pass: 56 | name: All jobs pass 57 | if: ${{ always() }} 58 | runs-on: ubuntu-latest 59 | needs: all-jobs-completed 60 | steps: 61 | - name: Check that all jobs have passed 62 | run: | 63 | passed="${{ needs.all-jobs-completed.outputs.PASSED }}" 64 | if [[ $passed != "true" ]]; then 65 | exit 1 66 | fi 67 | 68 | is-release: 69 | # Filtering by `push` events ensures that we only release from the `main` branch, which is a 70 | # requirement for our npm publishing environment. 71 | # The commit author should always be 'github-actions' for releases created by the 72 | # 'create-release-pr' workflow, so we filter by that as well to prevent accidentally 73 | # triggering a release. 74 | if: github.event_name == 'push' && startsWith(github.event.head_commit.author.name, 'github-actions') 75 | needs: all-jobs-pass 76 | outputs: 77 | IS_RELEASE: ${{ steps.is-release.outputs.IS_RELEASE }} 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: MetaMask/action-is-release@v1 81 | id: is-release 82 | 83 | publish-release: 84 | needs: is-release 85 | if: needs.is-release.outputs.IS_RELEASE == 'true' 86 | name: Publish release 87 | permissions: 88 | contents: write 89 | uses: ./.github/workflows/publish-release.yml 90 | secrets: 91 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 92 | PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} 93 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 94 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs to GitHub Pages 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | destination_dir: 7 | required: true 8 | type: string 9 | secrets: 10 | PUBLISH_DOCS_TOKEN: 11 | required: true 12 | 13 | jobs: 14 | publish-docs-to-gh-pages: 15 | name: Publish docs to GitHub Pages 16 | runs-on: ubuntu-latest 17 | environment: github-pages 18 | permissions: 19 | contents: write 20 | steps: 21 | - name: Ensure `destination_dir` is not empty 22 | if: ${{ inputs.destination_dir == '' }} 23 | run: exit 1 24 | - name: Checkout and setup environment 25 | uses: MetaMask/action-checkout-and-setup@v1 26 | with: 27 | is-high-risk-environment: true 28 | - name: Run build script 29 | run: yarn build:docs 30 | - name: Deploy to `${{ inputs.destination_dir }}` directory of `gh-pages` branch 31 | uses: peaceiris/actions-gh-pages@de7ea6f8efb354206b205ef54722213d99067935 32 | with: 33 | # This `PUBLISH_DOCS_TOKEN` needs to be manually set per-repository. 34 | # Look in the repository settings under "Environments", and set this token in the `github-pages` environment. 35 | personal_token: ${{ secrets.PUBLISH_DOCS_TOKEN }} 36 | publish_dir: ./docs 37 | destination_dir: ${{ inputs.destination_dir }} 38 | -------------------------------------------------------------------------------- /.github/workflows/publish-main-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish main branch docs to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: main 6 | 7 | jobs: 8 | publish-to-gh-pages: 9 | name: Publish docs to `staging` directory of `gh-pages` branch 10 | permissions: 11 | contents: write 12 | uses: ./.github/workflows/publish-docs.yml 13 | with: 14 | destination_dir: staging 15 | secrets: 16 | PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish-rc-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish release candidate docs to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 'release/**' 6 | 7 | jobs: 8 | get-release-version: 9 | name: Get release version 10 | runs-on: ubuntu-latest 11 | outputs: 12 | release-version: ${{ steps.release-name.outputs.RELEASE_VERSION }} 13 | steps: 14 | - name: Extract release version from branch name 15 | id: release-name 16 | run: | 17 | BRANCH_NAME='${{ github.ref_name }}' 18 | echo "RELEASE_VERSION=v${BRANCH_NAME#release/}" >> "$GITHUB_OUTPUT" 19 | publish-to-gh-pages: 20 | name: Publish docs to `rc-${{ needs.get-release-version.outputs.release-version }}` directory of `gh-pages` branch 21 | permissions: 22 | contents: write 23 | uses: ./.github/workflows/publish-docs.yml 24 | needs: get-release-version 25 | with: 26 | destination_dir: rc-${{ needs.get-release-version.outputs.release-version }} 27 | secrets: 28 | PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | NPM_TOKEN: 7 | required: true 8 | SLACK_WEBHOOK_URL: 9 | required: true 10 | PUBLISH_DOCS_TOKEN: 11 | required: true 12 | jobs: 13 | publish-release: 14 | permissions: 15 | contents: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout and setup environment 19 | uses: MetaMask/action-checkout-and-setup@v1 20 | with: 21 | is-high-risk-environment: true 22 | ref: ${{ github.sha }} 23 | - uses: MetaMask/action-publish-release@v3 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | - run: yarn build 27 | - name: Upload build artifacts 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: publish-release-artifacts-${{ github.sha }} 31 | retention-days: 4 32 | include-hidden-files: true 33 | path: | 34 | ./dist 35 | ./node_modules/.yarn-state.yml 36 | 37 | publish-npm-dry-run: 38 | needs: publish-release 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout and setup environment 42 | uses: MetaMask/action-checkout-and-setup@v1 43 | with: 44 | is-high-risk-environment: true 45 | ref: ${{ github.sha }} 46 | - name: Restore build artifacts 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: publish-release-artifacts-${{ github.sha }} 50 | - name: Dry Run Publish 51 | # omit npm-token token to perform dry run publish 52 | uses: MetaMask/action-npm-publish@v5 53 | with: 54 | slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} 55 | subteam: S042S7RE4AE # @metamask-npm-publishers 56 | env: 57 | SKIP_PREPACK: true 58 | 59 | publish-npm: 60 | needs: publish-npm-dry-run 61 | runs-on: ubuntu-latest 62 | environment: npm-publish 63 | steps: 64 | - name: Checkout and setup environment 65 | uses: MetaMask/action-checkout-and-setup@v1 66 | with: 67 | is-high-risk-environment: true 68 | ref: ${{ github.sha }} 69 | - name: Restore build artifacts 70 | uses: actions/download-artifact@v4 71 | with: 72 | name: publish-release-artifacts-${{ github.sha }} 73 | - name: Publish 74 | uses: MetaMask/action-npm-publish@v5 75 | with: 76 | # This `NPM_TOKEN` needs to be manually set per-repository. 77 | # Look in the repository settings under "Environments", and set this token in the `npm-publish` environment. 78 | npm-token: ${{ secrets.NPM_TOKEN }} 79 | env: 80 | SKIP_PREPACK: true 81 | 82 | get-release-version: 83 | needs: publish-npm 84 | runs-on: ubuntu-latest 85 | outputs: 86 | RELEASE_VERSION: ${{ steps.get-release-version.outputs.RELEASE_VERSION }} 87 | steps: 88 | - uses: actions/checkout@v4 89 | with: 90 | ref: ${{ github.sha }} 91 | - id: get-release-version 92 | shell: bash 93 | run: ./scripts/get.sh ".version" "RELEASE_VERSION" 94 | 95 | publish-release-to-gh-pages: 96 | name: Publish docs to `${{ needs.get-release-version.outputs.RELEASE_VERSION }}` directory of `gh-pages` branch 97 | needs: get-release-version 98 | permissions: 99 | contents: write 100 | uses: ./.github/workflows/publish-docs.yml 101 | with: 102 | destination_dir: ${{ needs.get-release-version.outputs.RELEASE_VERSION }} 103 | secrets: 104 | PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} 105 | 106 | publish-release-to-latest-gh-pages: 107 | name: Publish docs to `latest` directory of `gh-pages` branch 108 | needs: publish-npm 109 | permissions: 110 | contents: write 111 | uses: ./.github/workflows/publish-docs.yml 112 | with: 113 | destination_dir: latest 114 | secrets: 115 | PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} 116 | -------------------------------------------------------------------------------- /.github/workflows/security-code-scanner.yml: -------------------------------------------------------------------------------- 1 | name: MetaMask Security Code Scanner 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | SECURITY_SCAN_METRICS_TOKEN: 7 | required: false 8 | APPSEC_BOT_SLACK_WEBHOOK: 9 | required: false 10 | workflow_dispatch: 11 | 12 | jobs: 13 | run-security-scan: 14 | name: Run security scan 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | steps: 21 | - name: Analyse code 22 | uses: MetaMask/action-security-code-scanner@v1 23 | with: 24 | repo: ${{ github.repository }} 25 | paths_ignored: | 26 | .storybook/ 27 | '**/__snapshots__/' 28 | '**/*.snap' 29 | '**/*.stories.js' 30 | '**/*.stories.tsx' 31 | '**/*.test.browser.ts*' 32 | '**/*.test.js*' 33 | '**/*.test.ts*' 34 | '**/fixtures/' 35 | '**/jest.config.js' 36 | '**/jest.environment.js' 37 | '**/mocks/' 38 | '**/test*/' 39 | docs/ 40 | e2e/ 41 | merged-packages/ 42 | node_modules 43 | storybook/ 44 | test*/ 45 | rules_excluded: example 46 | project_metrics_token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} 47 | slack_webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | coverage/ 4 | docs/ 5 | dist-test/ 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | 40 | # TypeScript cache 41 | *.tsbuildinfo 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Microbundle cache 50 | .rpt2_cache/ 51 | .rts2_cache_cjs/ 52 | .rts2_cache_es/ 53 | .rts2_cache_umd/ 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | .env.test 67 | 68 | # Stores VSCode versions used for testing VSCode extensions 69 | .vscode-test 70 | 71 | # yarn v3 (w/o zero-install) 72 | # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 73 | .pnp.* 74 | .yarn/* 75 | !.yarn/patches 76 | !.yarn/plugins 77 | !.yarn/releases 78 | !.yarn/sdks 79 | !.yarn/versions 80 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // All of these are defaults except singleQuote, but we specify them 2 | // for explicitness 3 | module.exports = { 4 | quoteProps: 'as-needed', 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | }; 9 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-allow-scripts", 5 | factory: function (require) { 6 | var plugin=(()=>{var l=Object.defineProperty;var s=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var c=Object.prototype.hasOwnProperty;var p=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(o,e)=>(typeof require<"u"?require:o)[e]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var u=(t,o)=>{for(var e in o)l(t,e,{get:o[e],enumerable:!0})},f=(t,o,e,r)=>{if(o&&typeof o=="object"||typeof o=="function")for(let i of a(o))!c.call(t,i)&&i!==e&&l(t,i,{get:()=>o[i],enumerable:!(r=s(o,i))||r.enumerable});return t};var m=t=>f(l({},"__esModule",{value:!0}),t);var g={};u(g,{default:()=>d});var n=p("@yarnpkg/shell"),x={hooks:{afterAllInstalled:async()=>{let t=await(0,n.execute)("yarn run allow-scripts");t!==0&&process.exit(t)}}},d=x;return m(g);})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | enableScripts: false 6 | 7 | enableTelemetry: false 8 | 9 | logFilters: 10 | - code: YN0004 11 | level: discard 12 | 13 | nodeLinker: node-modules 14 | 15 | plugins: 16 | - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs 17 | spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [10.0.0] 11 | 12 | ### Changed 13 | 14 | - **BREAKING:** Build the package as both CJS and ESM ([#168](https://github.com/MetaMask/post-message-stream/pull/168)) 15 | - The package now uses the `exports` field in `package.json` to define the entry points for CJS and ESM. 16 | - It's no longer possible to import from the `dist` directory directly. 17 | - **BREAKING:** Move Node.js-specific functionality to `@metamask/post-message-stream/node` entry point ([#168](https://github.com/MetaMask/post-message-stream/pull/168)) 18 | - The main entry point is now compatible with Node.js and browsers. 19 | - Bump `@metamask/utils` from `^11.0.1` to `^11.4.0` ([#169](https://github.com/MetaMask/post-message-stream/pull/169)) 20 | - Bump `elliptic` from `6.6.0` to `6.6.1` ([#160](https://github.com/MetaMask/post-message-stream/pull/160)) 21 | 22 | ## [9.0.0] 23 | 24 | ### Changed 25 | 26 | - **BREAKING:** Increase minimum Node.js version to `^18.18` ([#145](https://github.com/MetaMask/post-message-stream/pull/145)) 27 | - Bump `@metamask/utils` from `^9.0.0` to `^11.0.1` ([#144](https://github.com/MetaMask/post-message-stream/pull/144), [#148](https://github.com/MetaMask/post-message-stream/pull/148), [#153](https://github.com/MetaMask/post-message-stream/pull/153)) 28 | 29 | ## [8.1.1] 30 | 31 | ### Changed 32 | 33 | - Bump `@metamask/utils` from `^8.1.0` to `^9.0.0` ([#140](https://github.com/MetaMask/post-message-stream/pull/140)) 34 | 35 | ## [8.1.0] 36 | 37 | ### Changed 38 | 39 | - Support overriding default Duplex Stream options as constructor options. ([#131](https://github.com/MetaMask/post-message-stream/pull/131)) 40 | 41 | ## [8.0.0] 42 | 43 | ### Changed 44 | 45 | - **BREAKING:** Increase minimum Node.js version to `^16.20.0` ([#110](https://github.com/MetaMask/post-message-stream/pull/110)) 46 | - Update `@metamask/utils` from `^5.0.2` to `^8.0.1` ([#108](https://github.com/MetaMask/post-message-stream/pull/108)) 47 | 48 | ## [7.0.0] 49 | 50 | ### Changed 51 | 52 | - **BREAKING:** Update `readable-stream` from `2.3.3` to `3.6.2` ([#88](https://github.com/MetaMask/post-message-stream/pull/88)) 53 | 54 | ## [6.2.0] 55 | 56 | ### Added 57 | 58 | - Add `setLogger` function for logging incoming/outgoing messages ([#93](https://github.com/MetaMask/post-message-stream/pull/93)) 59 | 60 | ## [6.1.2] 61 | 62 | ### Changed 63 | 64 | - Use `addEventListener` instead of `onmessage` in WebWorkerPostMessageStream ([#83](https://github.com/MetaMask/post-message-stream/pull/83)) 65 | - This fixes compatibility with LavaMoat. 66 | 67 | ## [6.1.1] 68 | 69 | ### Fixed 70 | 71 | - Fixed accessing MessageEvent prototype after event lockdown ([#79](https://github.com/MetaMask/post-message-stream/pull/79)) 72 | 73 | ## [6.1.0] 74 | 75 | ### Added 76 | 77 | - Add browser runtime post message stream ([#69](https://github.com/MetaMask/post-message-stream/pull/69)) 78 | 79 | ## [6.0.0] 80 | 81 | ### Changed 82 | 83 | - **BREAKING:** Use separate entrypoint for browser environments ([#49](https://github.com/MetaMask/post-message-stream/pull/49)) 84 | - This means `worker_threads` and `child_process` streams are no longer exposed in the browser. 85 | 86 | ## [5.1.0] 87 | 88 | ### Added 89 | 90 | - Export `BasePostMessageStream` ([#45](https://github.com/MetaMask/post-message-stream/pull/45)) 91 | 92 | ## [5.0.1] 93 | 94 | ### Security 95 | 96 | - Fix `WindowPostMessageStream` parameter documentation ([#43](https://github.com/MetaMask/post-message-stream/pull/43)) 97 | - The security implications of the `targetOrigin` and `targetWindow` parameters were mischaracterized in the [5.0.0] documentation. 98 | 99 | ## [5.0.0] 100 | 101 | ### Added 102 | 103 | - Add `StreamData` and `StreamMessage` types ([#37](https://github.com/MetaMask/post-message-stream/pull/37)) 104 | - Add `worker_threads` streams ([#39](https://github.com/MetaMask/post-message-stream/pull/39)) 105 | - Add `child_process` streams ([#34](https://github.com/MetaMask/post-message-stream/pull/34)) 106 | 107 | ### Changed 108 | 109 | - **BREAKING:** Increase minimum Node.js version to `^14.0.0` ([#38](https://github.com/MetaMask/post-message-stream/pull/38)) 110 | - **BREAKING:** Adopt a uniform naming scheme for all classes ([#40](https://github.com/MetaMask/post-message-stream/pull/40)) 111 | - **BREAKING:** Throw an error when constructing a stream in the wrong environment ([#40](https://github.com/MetaMask/post-message-stream/pull/40)) 112 | - For example, a `WebWorkerPostMessageStream` can now only be constructed in a `WebWorker`. This change may not be breaking in practice because the streams would not work in unintended environments anyway. 113 | - **BREAKING:** Add `targetOrigin` parameter for `WindowPostMessageStream` ([#23](https://github.com/MetaMask/post-message-stream/pull/23)) 114 | 115 | ## [4.0.0] - 2021-05-04 116 | 117 | ### Added 118 | 119 | - [#9](https://github.com/MetaMask/post-message-stream.git/pull/9): Add LICENSE file 120 | - [#6](https://github.com/MetaMask/post-message-stream.git/pull/6): Add `WorkerPostMessageStream` and `WorkerParentPostMessageStream` 121 | - [#18](https://github.com/MetaMask/post-message-stream.git/pull/18): Add changelog 122 | 123 | ### Changed 124 | 125 | - [#18](https://github.com/MetaMask/post-message-stream.git/pull/18): **(BREAKING)** Rename package to `@metamask/post-message-stream` 126 | - [#6](https://github.com/MetaMask/post-message-stream.git/pull/6): **(BREAKING)** Refactor exports 127 | - `PostMessageStream` default export now exported under name `WindowPostMessageStream` 128 | - [#13](https://github.com/MetaMask/post-message-stream.git/pull/13): Migrate to TypeScript, add typings 129 | 130 | ## [3.0.0] - 2017-07-13 131 | 132 | ### Changed 133 | 134 | - **(BREAKING)** Add handshake to mitigate synchronization issues 135 | 136 | ## [2.0.0] - 2017-01-17 137 | 138 | ### Added 139 | 140 | - `targetWindow` constructor option 141 | - README.md 142 | 143 | ## [1.0.0] - 2016-08-11 144 | 145 | ### Added 146 | 147 | - Initial release. 148 | 149 | [Unreleased]: https://github.com/MetaMask/post-message-stream/compare/v10.0.0...HEAD 150 | [10.0.0]: https://github.com/MetaMask/post-message-stream/compare/v9.0.0...v10.0.0 151 | [9.0.0]: https://github.com/MetaMask/post-message-stream/compare/v8.1.1...v9.0.0 152 | [8.1.1]: https://github.com/MetaMask/post-message-stream/compare/v8.1.0...v8.1.1 153 | [8.1.0]: https://github.com/MetaMask/post-message-stream/compare/v8.0.0...v8.1.0 154 | [8.0.0]: https://github.com/MetaMask/post-message-stream/compare/v7.0.0...v8.0.0 155 | [7.0.0]: https://github.com/MetaMask/post-message-stream/compare/v6.2.0...v7.0.0 156 | [6.2.0]: https://github.com/MetaMask/post-message-stream/compare/v6.1.2...v6.2.0 157 | [6.1.2]: https://github.com/MetaMask/post-message-stream/compare/v6.1.1...v6.1.2 158 | [6.1.1]: https://github.com/MetaMask/post-message-stream/compare/v6.1.0...v6.1.1 159 | [6.1.0]: https://github.com/MetaMask/post-message-stream/compare/v6.0.0...v6.1.0 160 | [6.0.0]: https://github.com/MetaMask/post-message-stream/compare/v5.1.0...v6.0.0 161 | [5.1.0]: https://github.com/MetaMask/post-message-stream/compare/v5.0.1...v5.1.0 162 | [5.0.1]: https://github.com/MetaMask/post-message-stream/compare/v5.0.0...v5.0.1 163 | [5.0.0]: https://github.com/MetaMask/post-message-stream/compare/v4.0.0...v5.0.0 164 | [4.0.0]: https://github.com/MetaMask/post-message-stream/compare/v3.0.0...v4.0.0 165 | [3.0.0]: https://github.com/MetaMask/post-message-stream/compare/v2.0.0...v3.0.0 166 | [2.0.0]: https://github.com/MetaMask/post-message-stream/compare/v1.0.0...v2.0.0 167 | [1.0.0]: https://github.com/MetaMask/post-message-stream/releases/tag/v1.0.0 168 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020 MetaMask 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @metamask/post-message-stream 2 | 3 | A Node.js duplex stream interface over various kinds of JavaScript inter-"process" communication channels, for Node.js and the Web. 4 | Originally the only communication channel used was `window.postMessage()`, but the package has since expanded in scope. 5 | 6 | ## Usage (Node.js) 7 | 8 | ### `ProcessParentMessageStream` and `ProcessMessageStream` 9 | 10 | Node.js [`child_process.fork()`](https://nodejs.org/api/child_process.html#child_processforkmodulepath-args-options) streams. 11 | The parent process creates a child process with a dedicated IPC channel using `child_process.fork()`. 12 | 13 | In the parent process: 14 | 15 | ```javascript 16 | import { fork } from 'child_process'; 17 | import { ProcessParentMessageStream } from '@metamask/post-message-stream'; 18 | 19 | // `modulePath` is the path to the JavaScript module that will instantiate the 20 | // child stream. 21 | const process = fork(modulePath); 22 | 23 | const parentStream = new ProcessParentMessageStream({ process }); 24 | parentStream.write('hello'); 25 | ``` 26 | 27 | In the child process: 28 | 29 | ```javascript 30 | import { ProcessMessageStream } from '@metamask/post-message-stream'; 31 | 32 | // The child stream automatically "connects" to the dedicated IPC channel via 33 | // properties on `globalThis.process`. 34 | const childStream = new ProcessMessageStream(); 35 | childStream.on('data', (data) => console.log(data + ', world')); 36 | // > 'hello, world' 37 | ``` 38 | 39 | ### `ThreadParentMessageStream` and `ThreadMessageStream` 40 | 41 | Node.js [`worker_threads`](https://nodejs.org/api/child_process.html#child_processforkmodulepath-args-options) streams. 42 | The parent process creates a worker thread using `new worker_threads.Worker()`. 43 | 44 | In the parent environment: 45 | 46 | ```javascript 47 | import { Worker } from 'worker_threads'; 48 | import { ThreadParentMessageStream } from '@metamask/post-message-stream'; 49 | 50 | // `modulePath` is the path to the JavaScript module that will instantiate the 51 | // child stream. 52 | const thread = new Worker(modulePath); 53 | 54 | const parentStream = new ThreadParentMessageStream({ thread }); 55 | parentStream.write('hello'); 56 | ``` 57 | 58 | In the child thread: 59 | 60 | ```javascript 61 | import { ThreadMessageStream } from '@metamask/post-message-stream'; 62 | 63 | // The child stream automatically "connects" to the parent via 64 | // `worker_threads.parentPort`. 65 | const childStream = new ThreadMessageStream(); 66 | childStream.on('data', (data) => console.log(data + ', world')); 67 | // > 'hello, world' 68 | ``` 69 | 70 | ## Usage (Web) 71 | 72 | ### `WebWorkerParentPostMessageStream` and `WebWorkerPostMessageStream` 73 | 74 | These streams are intended for **dedicated** [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) only. 75 | They might sort-of work with shared workers, but attempt that at your own risk. 76 | 77 | In the parent window: 78 | 79 | ```javascript 80 | import { WebWorkerParentPostMessageStream } from '@metamask/post-message-stream'; 81 | 82 | const worker = new Worker(url); 83 | 84 | const parentStream = new WebWorkerParentPostMessageStream({ worker }); 85 | parentStream.write('hello'); 86 | ``` 87 | 88 | In the child `WebWorker`: 89 | 90 | ```javascript 91 | import { WebWorkerPostMessageStream } from '@metamask/post-message-stream'; 92 | 93 | const workerStream = new WebWorkerPostMessageStream(); 94 | workerStream.on('data', (data) => console.log(data + ', world')); 95 | // > 'hello, world' 96 | ``` 97 | 98 | ### `WindowPostMessageStream` 99 | 100 | If you have two windows, A and B, that can communicate over `postMessage`, set up a stream in each. 101 | Be sure to make use of the `targetOrigin` and `targetWindow` parameters to ensure that you are communicating with your intended subject. 102 | 103 | In window A, with URL `https://foo.com`, trying to communicate with an iframe, `iframeB`: 104 | 105 | ```javascript 106 | import { WindowPostMessageStream } from '@metamask/post-message-stream'; 107 | 108 | const streamA = new WindowPostMessageStream({ 109 | name: 'streamA', // We give this stream a name that the other side can target. 110 | 111 | target: 'streamB', // This must match the `name` of the other side. 112 | 113 | // Adding `targetWindow` below already ensures that we will only _send_ 114 | // messages to `iframeB`, but we need to specify its origin as well to ensure 115 | // that we only _receive_ messages from `iframeB`. 116 | targetOrigin: new URL(iframeB.src).origin, 117 | 118 | // We have to specify the content window of `iframeB` as the target, or it 119 | // won't receive our messages. 120 | targetWindow: iframeB.contentWindow, 121 | }); 122 | 123 | streamA.write('hello'); 124 | ``` 125 | 126 | In window B, running in an iframe accessible in window A: 127 | 128 | ```javascript 129 | const streamB = new WindowPostMessageStream({ 130 | // Notice that these values are reversed relative to window A. 131 | name: 'streamB', 132 | target: 'streamA', 133 | 134 | // The origin of window A. If we don't specify this, it would default to 135 | // `location.origin`, which won't work if the local origin is different. We 136 | // could pass `*`, but that's potentially unsafe. 137 | targetOrigin: 'https://foo.com', 138 | 139 | // We omit `targetWindow` here because it defaults to `window`. 140 | }); 141 | 142 | streamB.on('data', (data) => console.log(data + ', world')); 143 | // > 'hello, world' 144 | ``` 145 | 146 | #### Gotchas 147 | 148 | Under the hood, `WindowPostMessageStream` uses `window.addEventListener('message', (event) => ...)`. 149 | If `event.source` is not referentially equal to the stream's `targetWindow`, all messages will be ignored. 150 | This can happen in environments where `window` objects are proxied, such as Electron. 151 | 152 | ## Contributing 153 | 154 | ### Setup 155 | 156 | - Install [Node.js](https://nodejs.org) version 12 157 | - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. 158 | - Install [Yarn v1](https://yarnpkg.com/en/docs/install) 159 | - Run `yarn setup` to install dependencies and run any requried post-install scripts 160 | - **Warning:** Do not use the `yarn` / `yarn install` command directly. Use `yarn setup` instead. The normal install command will skip required post-install scripts, leaving your development environment in an invalid state. 161 | 162 | ### Testing and Linting 163 | 164 | Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. 165 | 166 | Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. 167 | 168 | ### Release & Publishing 169 | 170 | The project follows the same release process as the other libraries in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. 171 | 172 | 1. Choose a release version. 173 | 174 | - The release version should be chosen according to SemVer. Analyze the changes to see whether they include any breaking changes, new features, or deprecations, then choose the appropriate SemVer version. See [the SemVer specification](https://semver.org/) for more information. 175 | 176 | 2. If this release is backporting changes onto a previous release, then ensure there is a major version branch for that version (e.g. `1.x` for a `v1` backport release). 177 | 178 | - The major version branch should be set to the most recent release with that major version. For example, when backporting a `v1.0.2` release, you'd want to ensure there was a `1.x` branch that was set to the `v1.0.1` tag. 179 | 180 | 3. Trigger the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event [manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) for the `Create Release Pull Request` action to create the release PR. 181 | 182 | - For a backport release, the base branch should be the major version branch that you ensured existed in step 2. For a normal release, the base branch should be the main branch for that repository (which should be the default value). 183 | - This should trigger the [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) workflow to create the release PR. 184 | 185 | 4. Update the changelog to move each change entry into the appropriate change category ([See here](https://keepachangelog.com/en/1.0.0/#types) for the full list of change categories, and the correct ordering), and edit them to be more easily understood by users of the package. 186 | 187 | - Generally any changes that don't affect consumers of the package (e.g. lockfile changes or development environment changes) are omitted. Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). 188 | - Try to explain each change in terms that users of the package would understand (e.g. avoid referencing internal variables/concepts). 189 | - Consolidate related changes into one change entry if it makes it easier to explain. 190 | - Run `yarn auto-changelog validate --rc` to check that the changelog is correctly formatted. 191 | 192 | 5. Review and QA the release. 193 | 194 | - If changes are made to the base branch, the release branch will need to be updated with these changes and review/QA will need to restart again. As such, it's probably best to avoid merging other PRs into the base branch while review is underway. 195 | 196 | 6. Squash & Merge the release. 197 | 198 | - This should trigger the [`action-publish-release`](https://github.com/MetaMask/action-publish-release) workflow to tag the final release commit and publish the release on GitHub. 199 | 200 | 7. Publish the release on npm. 201 | 202 | - Be very careful to use a clean local environment to publish the release, and follow exactly the same steps used during CI. 203 | - Use `npm publish --dry-run` to examine the release contents to ensure the correct files are included. Compare to previous releases if necessary (e.g. using `https://unpkg.com/browse/[package name]@[package version]/`). 204 | - Once you are confident the release contents are correct, publish the release using `npm publish`. 205 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: [ 4 | '/src/**/*.ts', 5 | '!/src/**/*.test.ts', 6 | '!/src/vendor/**/*', 7 | ], 8 | coverageDirectory: 'coverage', 9 | coverageReporters: ['html', 'json-summary', 'text'], 10 | coverageThreshold: { 11 | global: { 12 | branches: 100, 13 | functions: 100, 14 | lines: 100, 15 | statements: 100, 16 | }, 17 | }, 18 | moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'], 19 | preset: 'ts-jest', 20 | // "resetMocks" resets all mocks, including mocked modules, to jest.fn(), 21 | // between each test case. 22 | resetMocks: true, 23 | // "restoreMocks" restores all mocks created using jest.spyOn to their 24 | // original implementations, between each test. It does not affect mocked 25 | // modules. 26 | restoreMocks: true, 27 | projects: [ 28 | { 29 | displayName: 'Runner: default', 30 | preset: 'ts-jest', 31 | testRegex: ['\\.test\\.(ts|js)$'], 32 | testPathIgnorePatterns: [ 33 | '/src/web-worker/*', 34 | '/src/window/*', 35 | ], 36 | }, 37 | { 38 | displayName: 'Runner: Electron', 39 | preset: 'ts-jest', 40 | runner: '@jest-runner/electron', 41 | testEnvironment: '@jest-runner/electron/environment', 42 | testMatch: [ 43 | '/src/web-worker/*.test.ts', 44 | '/src/window/*.test.ts', 45 | ], 46 | }, 47 | ], 48 | testTimeout: 2500, 49 | }; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@metamask/post-message-stream", 3 | "version": "10.0.0", 4 | "description": "Sets up a duplex object stream over window.postMessage", 5 | "homepage": "https://github.com/MetaMask/post-message-stream#readme", 6 | "bugs": { 7 | "url": "https://github.com/MetaMask/post-message-stream/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/MetaMask/post-message-stream.git" 12 | }, 13 | "license": "ISC", 14 | "exports": { 15 | ".": { 16 | "import": { 17 | "types": "./dist/index.d.mts", 18 | "default": "./dist/index.mjs" 19 | }, 20 | "require": { 21 | "types": "./dist/index.d.cts", 22 | "default": "./dist/index.cjs" 23 | } 24 | }, 25 | "./node": { 26 | "import": { 27 | "types": "./dist/node.d.mts", 28 | "default": "./dist/node.mjs" 29 | }, 30 | "require": { 31 | "types": "./dist/node.d.cts", 32 | "default": "./dist/node.cjs" 33 | } 34 | }, 35 | "./package.json": "./package.json" 36 | }, 37 | "main": "./dist/index.cjs", 38 | "module": "./dist/index.mjs", 39 | "types": "./dist/index.d.cts", 40 | "files": [ 41 | "dist/" 42 | ], 43 | "scripts": { 44 | "build": "ts-bridge --project tsconfig.build.json --clean", 45 | "build:docs": "typedoc", 46 | "build:test": "./scripts/build-test.sh", 47 | "lint": "yarn lint:eslint && yarn lint:misc --check && yarn lint:dependencies --check && yarn lint:changelog", 48 | "lint:changelog": "auto-changelog validate --prettier", 49 | "lint:dependencies": "depcheck", 50 | "lint:eslint": "eslint . --cache", 51 | "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn lint:dependencies && yarn lint:changelog", 52 | "lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", 53 | "prepack": "./scripts/prepack.sh", 54 | "test": "jest && jest-it-up", 55 | "test:watch": "yarn test --watch" 56 | }, 57 | "dependencies": { 58 | "@metamask/utils": "^11.4.0", 59 | "readable-stream": "3.6.2" 60 | }, 61 | "devDependencies": { 62 | "@jest-runner/electron": "^3.0.1", 63 | "@lavamoat/allow-scripts": "^2.3.1", 64 | "@metamask/auto-changelog": "^3.0.0", 65 | "@metamask/eslint-config": "^10.0.0", 66 | "@metamask/eslint-config-jest": "^10.0.0", 67 | "@metamask/eslint-config-nodejs": "^10.0.0", 68 | "@metamask/eslint-config-typescript": "^10.0.0", 69 | "@ts-bridge/cli": "^0.6.3", 70 | "@types/chrome": "^0.0.204", 71 | "@types/jest": "^26.0.13", 72 | "@types/node": "^16.11.48", 73 | "@types/readable-stream": "^2.3.9", 74 | "@typescript-eslint/eslint-plugin": "^5.33.0", 75 | "@typescript-eslint/parser": "^5.33.0", 76 | "browserify": "^17.0.1", 77 | "depcheck": "^1.4.7", 78 | "electron": "^35.1.5", 79 | "eslint": "^8.21.0", 80 | "eslint-config-prettier": "^8.1.0", 81 | "eslint-plugin-import": "^2.26.0", 82 | "eslint-plugin-jest": "^26.8.2", 83 | "eslint-plugin-jsdoc": "^44.2.7", 84 | "eslint-plugin-node": "^11.1.0", 85 | "eslint-plugin-prettier": "^4.2.1", 86 | "jest": "^26.6.3", 87 | "jest-it-up": "^2.0.2", 88 | "prettier": "^2.7.1", 89 | "prettier-plugin-packagejson": "^2.2.17", 90 | "ts-jest": "^26.3.0", 91 | "typedoc": "^0.28.2", 92 | "typescript": "~4.9.5" 93 | }, 94 | "packageManager": "yarn@4.9.1", 95 | "engines": { 96 | "node": "^18.18 || ^20.14 || >=22" 97 | }, 98 | "publishConfig": { 99 | "access": "public", 100 | "registry": "https://registry.npmjs.org/" 101 | }, 102 | "lavamoat": { 103 | "allowScripts": { 104 | "@lavamoat/preinstall-always-fail": false, 105 | "electron": true 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /scripts/build-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -u 6 | set -o pipefail 7 | 8 | mkdir -p dist-test 9 | rm -rf dist-test/* 10 | browserify --standalone PostMessageStream ./dist/web-worker/WebWorkerPostMessageStream.cjs > ./dist-test/WebWorkerPostMessageStream.js 11 | browserify --standalone PostMessageStream ./dist/node-process/ProcessMessageStream.cjs > ./dist-test/ProcessMessageStream.js 12 | browserify --im --node --standalone PostMessageStream ./dist/node-thread/ThreadMessageStream.cjs > ./dist-test/ThreadMessageStream.js 13 | -------------------------------------------------------------------------------- /scripts/get.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -u 5 | set -o pipefail 6 | 7 | if [[ ${RUNNER_DEBUG:-0} == 1 ]]; then 8 | set -x 9 | fi 10 | 11 | KEY="${1}" 12 | OUTPUT="${2}" 13 | 14 | if [[ -z $KEY ]]; then 15 | echo "Error: KEY not specified." 16 | exit 1 17 | fi 18 | 19 | if [[ -z $OUTPUT ]]; then 20 | echo "Error: OUTPUT not specified." 21 | exit 1 22 | fi 23 | 24 | echo "$OUTPUT=$(jq --raw-output "$KEY" package.json)" >> "$GITHUB_OUTPUT" 25 | -------------------------------------------------------------------------------- /scripts/prepack.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | if [[ -n $SKIP_PREPACK ]]; then 7 | echo "Notice: skipping prepack." 8 | exit 0 9 | fi 10 | 11 | yarn build 12 | -------------------------------------------------------------------------------- /src/BasePostMessageStream.test.ts: -------------------------------------------------------------------------------- 1 | import { WindowPostMessageStream } from './window/WindowPostMessageStream'; 2 | 3 | describe('BasePostMessageStream', () => { 4 | let stream: WindowPostMessageStream; 5 | 6 | beforeEach(() => { 7 | stream = new WindowPostMessageStream({ 8 | name: 'name', 9 | target: 'target', 10 | }); 11 | }); 12 | 13 | afterEach(() => { 14 | stream.destroy(); 15 | }); 16 | 17 | it('checks logger receiving messages', () => { 18 | const log = jest.fn(); 19 | stream._setLogger(log); 20 | (stream as any)._init = true; 21 | (stream as any)._onData({ data: 123 }); 22 | expect(log).toHaveBeenCalledWith({ data: 123 }, false); 23 | }); 24 | 25 | it('checks logger sending messages', () => { 26 | const log = jest.fn(); 27 | stream._setLogger(log); 28 | (stream as any)._init = true; 29 | (stream as any)._write({ data: 123 }, null, () => undefined); 30 | expect(log).toHaveBeenCalledWith({ data: 123 }, true); 31 | }); 32 | 33 | it('handles errors thrown when pushing data', async () => { 34 | await new Promise((resolve) => { 35 | stream.push = () => { 36 | throw new Error('push error'); 37 | }; 38 | 39 | stream.once('error', (error) => { 40 | expect(error.message).toBe('push error'); 41 | resolve(); 42 | }); 43 | 44 | (stream as any)._init = true; 45 | (stream as any)._onData({}); 46 | }); 47 | }); 48 | 49 | it('does nothing if receiving junk data during synchronization', async () => { 50 | const _stream: any = stream; 51 | 52 | const spies = [ 53 | jest.spyOn(_stream, 'push').mockImplementation(), 54 | jest.spyOn(_stream, 'emit').mockImplementation(), 55 | jest.spyOn(_stream, '_write').mockImplementation(), 56 | jest.spyOn(_stream, 'uncork').mockImplementation(), 57 | ]; 58 | 59 | _stream._onData({}); 60 | spies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/BasePostMessageStream.ts: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'readable-stream'; 2 | import type { DuplexOptions } from 'readable-stream'; 3 | import { StreamData } from './utils'; 4 | 5 | const noop = () => undefined; 6 | 7 | const SYN = 'SYN'; 8 | const ACK = 'ACK'; 9 | 10 | type Log = (data: unknown, out: boolean) => void; 11 | 12 | export interface PostMessageEvent { 13 | data?: StreamData; 14 | origin: string; 15 | source: typeof window; 16 | } 17 | 18 | /** 19 | * Abstract base class for postMessage streams. 20 | */ 21 | export abstract class BasePostMessageStream extends Duplex { 22 | private _init: boolean; 23 | 24 | private _haveSyn: boolean; 25 | 26 | private _log: Log; 27 | 28 | constructor(streamOptions?: DuplexOptions) { 29 | super({ 30 | objectMode: true, 31 | ...streamOptions, 32 | }); 33 | 34 | // Initialization flags 35 | this._init = false; 36 | this._haveSyn = false; 37 | this._log = () => null; 38 | } 39 | 40 | /** 41 | * Must be called at end of child constructor to initiate 42 | * communication with other end. 43 | */ 44 | protected _handshake(): void { 45 | // Send synchronization message 46 | this._write(SYN, null, noop); 47 | this.cork(); 48 | } 49 | 50 | protected _onData(data: StreamData): void { 51 | if (this._init) { 52 | // Forward message 53 | try { 54 | this.push(data); 55 | this._log(data, false); 56 | } catch (err) { 57 | this.emit('error', err); 58 | } 59 | } else if (data === SYN) { 60 | // Listen for handshake 61 | this._haveSyn = true; 62 | this._write(ACK, null, noop); 63 | } else if (data === ACK) { 64 | this._init = true; 65 | if (!this._haveSyn) { 66 | this._write(ACK, null, noop); 67 | } 68 | this.uncork(); 69 | } 70 | } 71 | 72 | /** 73 | * Child classes must implement this function. 74 | */ 75 | protected abstract _postMessage(_data?: unknown): void; 76 | 77 | _read(): void { 78 | return undefined; 79 | } 80 | 81 | _write(data: StreamData, _encoding: string | null, cb: () => void): void { 82 | if (data !== ACK && data !== SYN) { 83 | this._log(data, true); 84 | } 85 | this._postMessage(data); 86 | cb(); 87 | } 88 | 89 | _setLogger(log: Log) { 90 | this._log = log; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as PostMessageStream from '.'; 2 | 3 | describe('post-message-stream', () => { 4 | describe('exports', () => { 5 | it('has expected exports', () => { 6 | expect(Object.keys(PostMessageStream).sort()).toMatchInlineSnapshot(` 7 | Array [ 8 | "BasePostMessageStream", 9 | "BrowserRuntimePostMessageStream", 10 | "WebWorkerParentPostMessageStream", 11 | "WebWorkerPostMessageStream", 12 | "WindowPostMessageStream", 13 | "isValidStreamMessage", 14 | ] 15 | `); 16 | }); 17 | 18 | it('exports `isValidStreamMessage`', () => { 19 | // Tested for coverage purposes. 20 | expect(PostMessageStream.isValidStreamMessage).toBeInstanceOf(Function); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './window/WindowPostMessageStream'; 2 | export * from './web-worker/WebWorkerPostMessageStream'; 3 | export * from './web-worker/WebWorkerParentPostMessageStream'; 4 | export * from './runtime/BrowserRuntimePostMessageStream'; 5 | export * from './BasePostMessageStream'; 6 | export type { StreamData, StreamMessage } from './utils'; 7 | export { isValidStreamMessage } from './utils'; 8 | -------------------------------------------------------------------------------- /src/node-process/ProcessMessageStream.ts: -------------------------------------------------------------------------------- 1 | import type { DuplexOptions } from 'readable-stream'; 2 | import { BasePostMessageStream } from '../BasePostMessageStream'; 3 | import { isValidStreamMessage, StreamData } from '../utils'; 4 | 5 | /** 6 | * Child process-side Node.js `child_process` stream. 7 | */ 8 | export class ProcessMessageStream extends BasePostMessageStream { 9 | constructor(streamOptions: DuplexOptions = {}) { 10 | super(streamOptions); 11 | 12 | if (typeof globalThis.process.send !== 'function') { 13 | throw new Error( 14 | 'Parent IPC channel not found. This class should only be instantiated in a Node.js child process.', 15 | ); 16 | } 17 | 18 | this._onMessage = this._onMessage.bind(this); 19 | globalThis.process.on('message', this._onMessage); 20 | 21 | this._handshake(); 22 | } 23 | 24 | protected _postMessage(data: StreamData): void { 25 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 26 | globalThis.process.send!({ data }); 27 | } 28 | 29 | private _onMessage(message: unknown): void { 30 | if (!isValidStreamMessage(message)) { 31 | return; 32 | } 33 | 34 | this._onData(message.data); 35 | } 36 | 37 | _destroy(): void { 38 | globalThis.process.removeListener('message', this._onMessage); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/node-process/ProcessParentMessageStream.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'child_process'; 2 | import type { DuplexOptions } from 'readable-stream'; 3 | import { BasePostMessageStream } from '../BasePostMessageStream'; 4 | import { isValidStreamMessage, StreamData } from '../utils'; 5 | 6 | export interface ProcessParentMessageStreamArgs extends DuplexOptions { 7 | process: ChildProcess; 8 | } 9 | 10 | /** 11 | * Parent-side Node.js `child_process` stream. 12 | */ 13 | export class ProcessParentMessageStream extends BasePostMessageStream { 14 | private _process: ChildProcess; 15 | 16 | /** 17 | * Creates a stream for communicating with a Node.js `child_process` process. 18 | * 19 | * @param args - Options bag. 20 | * @param args.process - The process to communicate with. 21 | */ 22 | constructor({ process, ...streamOptions }: ProcessParentMessageStreamArgs) { 23 | super(streamOptions); 24 | 25 | this._process = process; 26 | this._onMessage = this._onMessage.bind(this); 27 | this._process.on('message', this._onMessage); 28 | 29 | this._handshake(); 30 | } 31 | 32 | protected _postMessage(data: StreamData): void { 33 | this._process.send({ data }); 34 | } 35 | 36 | private _onMessage(message: unknown): void { 37 | if (!isValidStreamMessage(message)) { 38 | return; 39 | } 40 | 41 | this._onData(message.data); 42 | } 43 | 44 | _destroy(): void { 45 | this._process.removeListener('message', this._onMessage); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/node-process/node-process.test.ts: -------------------------------------------------------------------------------- 1 | import { fork } from 'child_process'; 2 | import EventEmitter from 'events'; 3 | import { readFileSync, writeFileSync } from 'fs'; 4 | import { ProcessMessageStream } from './ProcessMessageStream'; 5 | import { ProcessParentMessageStream } from './ProcessParentMessageStream'; 6 | 7 | const DIST_TEST_PATH = `${__dirname}/../../dist-test`; 8 | 9 | class MockProcess extends EventEmitter { 10 | send(..._args: any[]): void { 11 | return undefined; 12 | } 13 | } 14 | 15 | describe('Node Child Process Streams', () => { 16 | it('can communicate with a child process and be destroyed', async () => { 17 | const childProcessMessageStreamDist = readFileSync( 18 | `${DIST_TEST_PATH}/ProcessMessageStream.js`, 19 | 'utf8', 20 | ); 21 | 22 | // Create a stream that multiplies incoming data by 5 and returns it 23 | const setupProcessStream = ` 24 | const { ProcessMessageStream } = require('./ProcessMessageStream'); 25 | const stream = new ProcessMessageStream(); 26 | stream.on('data', (value) => stream.write(value * 5)); 27 | `; 28 | 29 | const code = `${childProcessMessageStreamDist}\n${setupProcessStream}`; 30 | 31 | const tmpFilePath = `${DIST_TEST_PATH}/childprocess-test.js`; 32 | writeFileSync(tmpFilePath, code); 33 | 34 | const childProcess = fork(tmpFilePath); 35 | 36 | // Create parent stream 37 | const parentStream = new ProcessParentMessageStream({ 38 | process: childProcess, 39 | }); 40 | 41 | // Get a deferred Promise for the eventual result 42 | const responsePromise = new Promise((resolve) => { 43 | parentStream.once('data', (num) => { 44 | resolve(Number(num)); 45 | }); 46 | }); 47 | 48 | // The child should ignore this 49 | childProcess.send('foo'); 50 | 51 | // Send message to child, triggering a response 52 | parentStream.write(111); 53 | 54 | expect(await responsePromise).toBe(555); 55 | 56 | // Check that events with falsy data are ignored as expected 57 | parentStream.once('data', (data) => { 58 | throw new Error(`Unexpected data on stream: ${data}`); 59 | }); 60 | childProcess.send(new Event('message')); 61 | 62 | // Terminate child process, destroy parent stream, and check that parent 63 | // was destroyed 64 | childProcess.kill(); 65 | parentStream.destroy(); 66 | expect(parentStream.destroyed).toBe(true); 67 | }); 68 | 69 | describe('ProcessParentMessageStream', () => { 70 | it('ignores invalid messages', () => { 71 | const mockProcess: any = new MockProcess(); 72 | const stream = new ProcessParentMessageStream({ process: mockProcess }); 73 | const onDataSpy = jest 74 | .spyOn(stream, '_onData' as any) 75 | .mockImplementation(); 76 | 77 | [null, undefined, 'foo', 42, {}, { data: null }].forEach( 78 | (invalidMessage) => { 79 | mockProcess.emit('message', invalidMessage); 80 | 81 | expect(onDataSpy).not.toHaveBeenCalled(); 82 | }, 83 | ); 84 | }); 85 | }); 86 | 87 | describe('ProcessMessageStream', () => { 88 | let stream: ProcessMessageStream; 89 | 90 | beforeEach(() => { 91 | stream = new ProcessMessageStream(); 92 | }); 93 | 94 | afterEach(() => { 95 | stream.destroy(); 96 | }); 97 | 98 | it('throws if not run in a child process', () => { 99 | const originalSend = globalThis.process.send; 100 | 101 | globalThis.process.send = undefined; 102 | expect(() => new ProcessMessageStream()).toThrow( 103 | 'Parent IPC channel not found. This class should only be instantiated in a Node.js child process.', 104 | ); 105 | 106 | globalThis.process.send = originalSend; 107 | }); 108 | 109 | it('forwards valid messages', () => { 110 | const onDataSpy = jest 111 | .spyOn(stream, '_onData' as any) 112 | .mockImplementation(); 113 | 114 | (process as any).emit('message', { data: 'bar' }); 115 | 116 | expect(onDataSpy).toHaveBeenCalledTimes(1); 117 | expect(onDataSpy).toHaveBeenCalledWith('bar'); 118 | }); 119 | 120 | it('ignores invalid messages', () => { 121 | const onDataSpy = jest 122 | .spyOn(stream, '_onData' as any) 123 | .mockImplementation(); 124 | 125 | [null, undefined, 'foo', 42, {}, { data: null }].forEach( 126 | (invalidMessage) => { 127 | (process as any).emit('message', invalidMessage); 128 | 129 | expect(onDataSpy).not.toHaveBeenCalled(); 130 | }, 131 | ); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/node-thread/ThreadMessageStream.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'stream'; 2 | import { parentPort } from 'worker_threads'; 3 | import { ThreadMessageStream } from './ThreadMessageStream'; 4 | 5 | // This file is kept separate due to this module mock. 6 | jest.mock('worker_threads', () => ({ 7 | parentPort: { 8 | on: jest.fn(), 9 | postMessage: jest.fn(), 10 | removeListener: jest.fn(), 11 | }, 12 | })); 13 | 14 | describe('ThreadMessageStream', () => { 15 | it('forwards valid messages', () => { 16 | const emitter = new EventEmitter(); 17 | jest 18 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 19 | .spyOn(parentPort!, 'on') 20 | .mockImplementation( 21 | (event, listener) => emitter.on(event, listener) as any, 22 | ); 23 | const stream = new ThreadMessageStream(); 24 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 25 | expect(parentPort!.on).toHaveBeenCalledWith( 26 | 'message', 27 | expect.any(Function), 28 | ); 29 | 30 | const onDataSpy = jest.spyOn(stream, '_onData' as any).mockImplementation(); 31 | emitter.emit('message', { data: 'bar' }); 32 | 33 | expect(onDataSpy).toHaveBeenCalledTimes(1); 34 | expect(onDataSpy).toHaveBeenCalledWith('bar'); 35 | }); 36 | 37 | it('ignores invalid messages', () => { 38 | const emitter = new EventEmitter(); 39 | jest 40 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 41 | .spyOn(parentPort!, 'on') 42 | .mockImplementation( 43 | (event, listener) => emitter.on(event, listener) as any, 44 | ); 45 | const stream = new ThreadMessageStream(); 46 | const onDataSpy = jest.spyOn(stream, '_onData' as any).mockImplementation(); 47 | 48 | [null, undefined, 'foo', 42, {}, { data: null }].forEach( 49 | (invalidMessage) => { 50 | emitter.emit('message', invalidMessage); 51 | 52 | expect(onDataSpy).not.toHaveBeenCalled(); 53 | }, 54 | ); 55 | }); 56 | 57 | it('can be destroyed', () => { 58 | const stream = new ThreadMessageStream(); 59 | stream.destroy(); 60 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 61 | expect(parentPort!.removeListener).toHaveBeenCalled(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/node-thread/ThreadMessageStream.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'worker_threads'; 2 | import type { DuplexOptions } from 'readable-stream'; 3 | import { BasePostMessageStream } from '../BasePostMessageStream'; 4 | import { isValidStreamMessage, StreamData } from '../utils'; 5 | 6 | /** 7 | * Child thread-side Node.js `worker_threads` stream. 8 | */ 9 | export class ThreadMessageStream extends BasePostMessageStream { 10 | #parentPort: Exclude; 11 | 12 | constructor(streamOptions: DuplexOptions = {}) { 13 | super(streamOptions); 14 | 15 | if (!parentPort) { 16 | throw new Error( 17 | 'Parent port not found. This class should only be instantiated in a Node.js worker thread.', 18 | ); 19 | } 20 | 21 | this.#parentPort = parentPort; 22 | 23 | this._onMessage = this._onMessage.bind(this); 24 | this.#parentPort.on('message', this._onMessage); 25 | 26 | this._handshake(); 27 | } 28 | 29 | protected _postMessage(data: StreamData): void { 30 | this.#parentPort.postMessage({ data }); 31 | } 32 | 33 | private _onMessage(message: unknown): void { 34 | if (!isValidStreamMessage(message)) { 35 | return; 36 | } 37 | 38 | this._onData(message.data); 39 | } 40 | 41 | _destroy(): void { 42 | this.#parentPort.removeListener('message', this._onMessage); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/node-thread/ThreadParentMessageStream.ts: -------------------------------------------------------------------------------- 1 | import { Worker } from 'worker_threads'; 2 | import type { DuplexOptions } from 'readable-stream'; 3 | import { BasePostMessageStream } from '../BasePostMessageStream'; 4 | import { isValidStreamMessage, StreamData } from '../utils'; 5 | 6 | export interface ThreadParentMessageStreamArgs extends DuplexOptions { 7 | thread: Worker; 8 | } 9 | 10 | /** 11 | * Parent-side Node.js `worker_threads` stream. 12 | */ 13 | export class ThreadParentMessageStream extends BasePostMessageStream { 14 | private _thread: Worker; 15 | 16 | /** 17 | * Creates a stream for communicating with a Node.js `worker_threads` thread. 18 | * 19 | * @param args - Options bag. 20 | * @param args.thread - The thread to communicate with. 21 | */ 22 | constructor({ thread, ...streamOptions }: ThreadParentMessageStreamArgs) { 23 | super(streamOptions); 24 | 25 | this._thread = thread; 26 | this._onMessage = this._onMessage.bind(this); 27 | this._thread.on('message', this._onMessage); 28 | 29 | this._handshake(); 30 | } 31 | 32 | protected _postMessage(data: StreamData): void { 33 | this._thread.postMessage({ data }); 34 | } 35 | 36 | private _onMessage(message: unknown): void { 37 | if (!isValidStreamMessage(message)) { 38 | return; 39 | } 40 | 41 | this._onData(message.data); 42 | } 43 | 44 | _destroy(): void { 45 | this._thread.removeListener('message', this._onMessage); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/node-thread/node-thread.test.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | import { readFileSync, writeFileSync } from 'fs'; 3 | import { Worker } from 'worker_threads'; 4 | import { ThreadParentMessageStream } from './ThreadParentMessageStream'; 5 | import { ThreadMessageStream } from './ThreadMessageStream'; 6 | 7 | const DIST_TEST_PATH = `${__dirname}/../../dist-test`; 8 | 9 | class MockThread extends EventEmitter { 10 | postMessage(..._args: any[]): void { 11 | return undefined; 12 | } 13 | } 14 | 15 | describe('Node Thread Streams', () => { 16 | it('can communicate with a thread and be destroyed', async () => { 17 | const dist = readFileSync( 18 | `${DIST_TEST_PATH}/ThreadMessageStream.js`, 19 | 'utf8', 20 | ); 21 | 22 | // Create a stream that multiplies incoming data by 5 and returns it 23 | const setupThreadStream = ` 24 | const { ThreadMessageStream } = require('./ThreadMessageStream'); 25 | const stream = new ThreadMessageStream(); 26 | stream.on('data', (value) => stream.write(value * 5)); 27 | `; 28 | 29 | const code = `${dist}\n${setupThreadStream}`; 30 | 31 | const tmpFilePath = `${DIST_TEST_PATH}/thread-test.js`; 32 | writeFileSync(tmpFilePath, code); 33 | 34 | const thread = new Worker(tmpFilePath); 35 | 36 | // Create parent stream 37 | const parentStream = new ThreadParentMessageStream({ thread }); 38 | 39 | // Get a deferred Promise for the eventual result 40 | const responsePromise = new Promise((resolve) => { 41 | parentStream.once('data', (num) => { 42 | resolve(Number(num)); 43 | }); 44 | }); 45 | 46 | // The child should ignore this 47 | thread.postMessage('foo'); 48 | 49 | // Send message to child, triggering a response 50 | parentStream.write(111); 51 | 52 | expect(await responsePromise).toBe(555); 53 | 54 | // Check that events with falsy data are ignored as expected 55 | parentStream.once('data', (data) => { 56 | throw new Error(`Unexpected data on stream: ${data}`); 57 | }); 58 | thread.postMessage(new Event('message')); 59 | 60 | // Terminate child thread, destroy parent stream, and check that parent 61 | // was destroyed 62 | thread.terminate(); 63 | parentStream.destroy(); 64 | expect(parentStream.destroyed).toBe(true); 65 | }); 66 | 67 | describe('ThreadParentMessageStream', () => { 68 | it('ignores invalid messages', () => { 69 | const mockThread: any = new MockThread(); 70 | const stream = new ThreadParentMessageStream({ thread: mockThread }); 71 | const onDataSpy = jest 72 | .spyOn(stream, '_onData' as any) 73 | .mockImplementation(); 74 | 75 | [null, undefined, 'foo', 42, {}, { data: null }].forEach( 76 | (invalidMessage) => { 77 | mockThread.emit('message', invalidMessage); 78 | 79 | expect(onDataSpy).not.toHaveBeenCalled(); 80 | }, 81 | ); 82 | }); 83 | }); 84 | 85 | describe('ThreadMessageStream', () => { 86 | it('throws if not run in a worker thread', () => { 87 | expect(() => new ThreadMessageStream()).toThrow( 88 | 'Parent port not found. This class should only be instantiated in a Node.js worker thread.', 89 | ); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/node.test.ts: -------------------------------------------------------------------------------- 1 | import * as PostMessageStream from './node'; 2 | 3 | describe('post-message-stream/node', () => { 4 | describe('exports', () => { 5 | it('has expected exports', () => { 6 | expect(Object.keys(PostMessageStream).sort()).toMatchInlineSnapshot(` 7 | Array [ 8 | "BasePostMessageStream", 9 | "BrowserRuntimePostMessageStream", 10 | "ProcessMessageStream", 11 | "ProcessParentMessageStream", 12 | "ThreadMessageStream", 13 | "ThreadParentMessageStream", 14 | "WebWorkerParentPostMessageStream", 15 | "WebWorkerPostMessageStream", 16 | "WindowPostMessageStream", 17 | "isValidStreamMessage", 18 | ] 19 | `); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | export * from '.'; 2 | export * from './node-process/ProcessParentMessageStream'; 3 | export * from './node-process/ProcessMessageStream'; 4 | export * from './node-thread/ThreadParentMessageStream'; 5 | export * from './node-thread/ThreadMessageStream'; 6 | -------------------------------------------------------------------------------- /src/runtime/BrowserRuntimePostMessageStream.test.ts: -------------------------------------------------------------------------------- 1 | import { BrowserRuntimePostMessageStream } from './BrowserRuntimePostMessageStream'; 2 | 3 | describe('BrowserRuntimePostMessageStream', () => { 4 | beforeEach(() => { 5 | const addListener = jest.fn(); 6 | const sendMessage = jest.fn().mockImplementation((message) => { 7 | // Propagate message to all listeners. 8 | addListener.mock.calls.forEach(([listener]) => { 9 | setTimeout(() => listener(message)); 10 | }); 11 | }); 12 | 13 | Object.assign(global, { 14 | chrome: undefined, 15 | browser: { 16 | runtime: { 17 | sendMessage, 18 | onMessage: { 19 | addListener, 20 | removeListener: jest.fn(), 21 | }, 22 | }, 23 | }, 24 | }); 25 | }); 26 | 27 | it('throws if browser.runtime.sendMessage is not a function', () => { 28 | // @ts-expect-error - Invalid function type. 29 | browser.runtime.sendMessage = undefined; 30 | 31 | expect( 32 | () => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }), 33 | ).toThrow( 34 | 'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.', 35 | ); 36 | 37 | // @ts-expect-error - Invalid function type. 38 | browser.runtime.sendMessage = 'foo'; 39 | 40 | expect( 41 | () => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }), 42 | ).toThrow( 43 | 'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.', 44 | ); 45 | 46 | // @ts-expect-error - Invalid function type. 47 | browser.runtime = undefined; 48 | 49 | expect( 50 | () => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }), 51 | ).toThrow( 52 | 'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.', 53 | ); 54 | 55 | // @ts-expect-error - Invalid function type. 56 | browser = undefined; 57 | 58 | expect( 59 | () => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }), 60 | ).toThrow( 61 | 'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.', 62 | ); 63 | }); 64 | 65 | it('supports chrome.runtime', () => { 66 | const addListener = jest.fn(); 67 | const sendMessage = jest.fn().mockImplementation((message) => { 68 | // Propagate message to all listeners. 69 | addListener.mock.calls.forEach(([listener]) => { 70 | setTimeout(() => listener(message)); 71 | }); 72 | }); 73 | 74 | Object.assign(global, { 75 | browser: undefined, 76 | chrome: { 77 | runtime: { 78 | sendMessage, 79 | onMessage: { 80 | addListener, 81 | removeListener: jest.fn(), 82 | }, 83 | }, 84 | }, 85 | }); 86 | 87 | expect( 88 | () => new BrowserRuntimePostMessageStream({ name: 'foo', target: 'bar' }), 89 | ).not.toThrow(); 90 | }); 91 | 92 | it('can communicate between streams and be destroyed', async () => { 93 | // Initialize sender stream 94 | const streamA = new BrowserRuntimePostMessageStream({ 95 | name: 'a', 96 | target: 'b', 97 | }); 98 | 99 | // Initialize receiver stream. Multiplies incoming values by 5 and 100 | // returns them. 101 | const streamB = new BrowserRuntimePostMessageStream({ 102 | name: 'b', 103 | target: 'a', 104 | }); 105 | 106 | streamB.on('data', (value) => streamB.write(value * 5)); 107 | 108 | // Get a deferred Promise for the result 109 | const responsePromise = new Promise((resolve) => { 110 | streamA.once('data', (num) => { 111 | resolve(Number(num)); 112 | }); 113 | }); 114 | 115 | // Write to stream A, triggering a response from stream B 116 | streamA.write(111); 117 | 118 | expect(await responsePromise).toBe(555); 119 | 120 | const throwingListener = (data: any) => { 121 | throw new Error(`Unexpected data on stream: ${data}`); 122 | }; 123 | 124 | streamA.once('data', throwingListener); 125 | streamB.once('data', throwingListener); 126 | 127 | browser.runtime.sendMessage(new Event('message')); 128 | 129 | // Destroy streams and confirm that they were destroyed 130 | streamA.destroy(); 131 | streamB.destroy(); 132 | expect(streamA.destroyed).toBe(true); 133 | expect(streamB.destroyed).toBe(true); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/runtime/BrowserRuntimePostMessageStream.ts: -------------------------------------------------------------------------------- 1 | import type { DuplexOptions } from 'readable-stream'; 2 | import { 3 | BasePostMessageStream, 4 | PostMessageEvent, 5 | } from '../BasePostMessageStream'; 6 | import { isValidStreamMessage } from '../utils'; 7 | 8 | export interface BrowserRuntimePostMessageStreamArgs extends DuplexOptions { 9 | name: string; 10 | target: string; 11 | } 12 | 13 | /** 14 | * A {@link browser.runtime} stream. 15 | */ 16 | export class BrowserRuntimePostMessageStream extends BasePostMessageStream { 17 | #name: string; 18 | 19 | #target: string; 20 | 21 | /** 22 | * Creates a stream for communicating with other streams across the extension 23 | * runtime. 24 | * 25 | * @param args - Options bag. 26 | * @param args.name - The name of the stream. Used to differentiate between 27 | * multiple streams sharing the same runtime. 28 | * @param args.target - The name of the stream to exchange messages with. 29 | */ 30 | constructor({ 31 | name, 32 | target, 33 | ...streamOptions 34 | }: BrowserRuntimePostMessageStreamArgs) { 35 | super(streamOptions); 36 | 37 | this.#name = name; 38 | this.#target = target; 39 | this._onMessage = this._onMessage.bind(this); 40 | 41 | this._getRuntime().onMessage.addListener(this._onMessage); 42 | 43 | this._handshake(); 44 | } 45 | 46 | protected _postMessage(data: unknown): void { 47 | // This returns a Promise, which resolves if the receiver responds to the 48 | // message. Rather than responding to specific messages, we send new 49 | // messages in response to incoming messages, so we don't care about the 50 | // Promise. 51 | this._getRuntime().sendMessage({ 52 | target: this.#target, 53 | data, 54 | }); 55 | } 56 | 57 | private _onMessage(message: PostMessageEvent): void { 58 | if (!isValidStreamMessage(message) || message.target !== this.#name) { 59 | return; 60 | } 61 | 62 | this._onData(message.data); 63 | } 64 | 65 | private _getRuntime(): typeof browser.runtime { 66 | if ( 67 | 'chrome' in globalThis && 68 | typeof chrome?.runtime?.sendMessage === 'function' 69 | ) { 70 | return chrome.runtime; 71 | } 72 | 73 | if ( 74 | 'browser' in globalThis && 75 | typeof browser?.runtime?.sendMessage === 'function' 76 | ) { 77 | return browser.runtime; 78 | } 79 | 80 | throw new Error( 81 | 'browser.runtime.sendMessage is not a function. This class should only be instantiated in a web extension.', 82 | ); 83 | } 84 | 85 | _destroy(): void { 86 | this._getRuntime().onMessage.removeListener(this._onMessage); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from '@metamask/utils'; 2 | 3 | export const DEDICATED_WORKER_NAME = 'dedicatedWorker'; 4 | 5 | export type StreamData = number | string | Record | unknown[]; 6 | 7 | export interface StreamMessage { 8 | data: StreamData; 9 | [key: string]: unknown; 10 | } 11 | 12 | /** 13 | * Checks whether the specified stream event message is valid per the 14 | * expectations of this library. 15 | * 16 | * @param message - The stream event message property. 17 | * @returns Whether the `message` is a valid stream message. 18 | */ 19 | export function isValidStreamMessage( 20 | message: unknown, 21 | ): message is StreamMessage { 22 | return ( 23 | isObject(message) && 24 | Boolean(message.data) && 25 | (typeof message.data === 'number' || 26 | typeof message.data === 'object' || 27 | typeof message.data === 'string') 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/vendor/types/browser.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/unambiguous 2 | declare namespace browser.runtime { 3 | export function sendMessage(message: any): Promise; 4 | 5 | export const onMessage: { 6 | addListener(listener: (message: any) => void): void; 7 | removeListener(listener: (message: any) => void): void; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/web-worker/WebWorker.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { DEDICATED_WORKER_NAME } from '../utils'; 3 | import { WebWorkerParentPostMessageStream } from './WebWorkerParentPostMessageStream'; 4 | import { WebWorkerPostMessageStream } from './WebWorkerPostMessageStream'; 5 | 6 | const DIST_TEST_PATH = `${__dirname}/../../dist-test`; 7 | 8 | describe('WebWorker Streams', () => { 9 | it('can communicate with a worker and be destroyed', async () => { 10 | const workerPostMessageStreamDist = readFileSync( 11 | `${DIST_TEST_PATH}/WebWorkerPostMessageStream.js`, 12 | 'utf8', 13 | ); 14 | 15 | // Create a stream that multiplies incoming data by 5 and returns it 16 | const setupWorkerStream = ` 17 | const stream = new self.PostMessageStream.WebWorkerPostMessageStream(); 18 | stream.on('data', (value) => stream.write(value * 5)); 19 | `; 20 | 21 | const code = `${workerPostMessageStreamDist}\n${setupWorkerStream}`; 22 | const worker = new Worker(URL.createObjectURL(new Blob([code]))); 23 | 24 | // Create parent stream 25 | const parentStream = new WebWorkerParentPostMessageStream({ worker }); 26 | 27 | // Get a deferred Promise for the eventual result 28 | const responsePromise = new Promise((resolve) => { 29 | parentStream.once('data', (num) => { 30 | resolve(Number(num)); 31 | }); 32 | }); 33 | 34 | // The worker should ignore this 35 | worker.postMessage('foo'); 36 | 37 | // Send message to worker, triggering a response 38 | parentStream.write(111); 39 | 40 | expect(await responsePromise).toBe(555); 41 | 42 | // Check that events with falsy data are ignored as expected 43 | parentStream.once('data', (data) => { 44 | throw new Error(`Unexpected data on stream: ${data}`); 45 | }); 46 | worker.dispatchEvent(new Event('message')); 47 | 48 | // Terminate worker, destroy parent, and check that parent was destroyed 49 | worker.terminate(); 50 | parentStream.destroy(); 51 | expect(parentStream.destroyed).toBe(true); 52 | }); 53 | 54 | describe('WebWorkerPostMessageStream', () => { 55 | class WorkerGlobalScope { 56 | postMessage = jest.fn(); 57 | 58 | onmessage = undefined; 59 | } 60 | 61 | const originalSelf: any = self; 62 | 63 | beforeEach(() => { 64 | (globalThis as any).WorkerGlobalScope = WorkerGlobalScope; 65 | (globalThis as any).self = new WorkerGlobalScope(); 66 | }); 67 | 68 | afterEach(() => { 69 | delete (globalThis as any).WorkerGlobalScope; 70 | (globalThis as any).self = originalSelf; 71 | }); 72 | 73 | it('throws if not run in a WebWorker (self undefined)', () => { 74 | (globalThis as any).self = undefined; 75 | expect(() => new WebWorkerPostMessageStream()).toThrow( 76 | 'WorkerGlobalScope not found. This class should only be instantiated in a WebWorker.', 77 | ); 78 | }); 79 | 80 | it('throws if not run in a WebWorker (WorkerGlobalScope undefined)', () => { 81 | (globalThis as any).WorkerGlobalScope = undefined; 82 | expect(() => new WebWorkerPostMessageStream()).toThrow( 83 | 'WorkerGlobalScope not found. This class should only be instantiated in a WebWorker.', 84 | ); 85 | }); 86 | 87 | it('can be destroyed', () => { 88 | (globalThis as any).self = originalSelf; 89 | const stream = new WebWorkerPostMessageStream(); 90 | expect(stream.destroy().destroyed).toBe(true); 91 | }); 92 | 93 | it('forwards valid messages', () => { 94 | (globalThis as any).self = originalSelf; 95 | const addEventListenerSpy = jest.spyOn(globalThis, 'addEventListener'); 96 | const stream = new WebWorkerPostMessageStream(); 97 | 98 | const onDataSpy = jest 99 | .spyOn(stream, '_onData' as any) 100 | .mockImplementation(); 101 | 102 | expect(addEventListenerSpy).toHaveBeenCalledTimes(1); 103 | const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; 104 | 105 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 106 | listener( 107 | new MessageEvent('foo', { 108 | data: { data: 'bar', target: DEDICATED_WORKER_NAME }, 109 | }), 110 | ); 111 | 112 | expect(onDataSpy).toHaveBeenCalledTimes(1); 113 | expect(onDataSpy).toHaveBeenCalledWith('bar'); 114 | }); 115 | 116 | it('ignores invalid messages', () => { 117 | (globalThis as any).self = originalSelf; 118 | const addEventListenerSpy = jest.spyOn(globalThis, 'addEventListener'); 119 | const stream = new WebWorkerPostMessageStream(); 120 | 121 | const onDataSpy = jest 122 | .spyOn(stream, '_onData' as any) 123 | .mockImplementation(); 124 | 125 | expect(addEventListenerSpy).toHaveBeenCalledTimes(2); 126 | const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; 127 | 128 | ( 129 | [ 130 | { data: 'bar' }, 131 | { data: { data: 'bar', target: 'foo' } }, 132 | { data: { data: null, target: 'foo' } }, 133 | ] as const 134 | ).forEach((invalidMessage) => { 135 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 136 | listener(new MessageEvent('foo', invalidMessage)); 137 | 138 | expect(onDataSpy).not.toHaveBeenCalled(); 139 | }); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/web-worker/WebWorkerParentPostMessageStream.ts: -------------------------------------------------------------------------------- 1 | import type { DuplexOptions } from 'readable-stream'; 2 | import { 3 | BasePostMessageStream, 4 | PostMessageEvent, 5 | } from '../BasePostMessageStream'; 6 | import { DEDICATED_WORKER_NAME, isValidStreamMessage } from '../utils'; 7 | 8 | export interface WorkerParentStreamArgs extends DuplexOptions { 9 | worker: Worker; 10 | } 11 | 12 | /** 13 | * Parent-side dedicated `WebWorker.postMessage` stream. Designed for use with 14 | * dedicated workers only. 15 | */ 16 | export class WebWorkerParentPostMessageStream extends BasePostMessageStream { 17 | private _target: string; 18 | 19 | private _worker: Worker; 20 | 21 | /** 22 | * Creates a stream for communicating with a dedicated `WebWorker`. 23 | * 24 | * @param args - Options bag. 25 | * @param args.worker - The Web Worker to exchange messages with. The worker 26 | * must instantiate a `WebWorkerPostMessageStream`. 27 | */ 28 | constructor({ worker, ...streamOptions }: WorkerParentStreamArgs) { 29 | super(streamOptions); 30 | 31 | this._target = DEDICATED_WORKER_NAME; 32 | this._worker = worker; 33 | this._worker.onmessage = this._onMessage.bind(this) as any; 34 | 35 | this._handshake(); 36 | } 37 | 38 | protected _postMessage(data: unknown): void { 39 | this._worker.postMessage({ 40 | target: this._target, 41 | data, 42 | }); 43 | } 44 | 45 | private _onMessage(event: PostMessageEvent): void { 46 | const message = event.data; 47 | 48 | if (!isValidStreamMessage(message)) { 49 | return; 50 | } 51 | 52 | this._onData(message.data); 53 | } 54 | 55 | _destroy(): void { 56 | this._worker.onmessage = null; 57 | this._worker = null as any; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/web-worker/WebWorkerPostMessageStream.ts: -------------------------------------------------------------------------------- 1 | // We ignore coverage for the entire file due to limits on our instrumentation, 2 | // but it is in fact covered by our tests. 3 | import type { DuplexOptions } from 'readable-stream'; 4 | import { 5 | BasePostMessageStream, 6 | PostMessageEvent, 7 | } from '../BasePostMessageStream'; 8 | import { 9 | DEDICATED_WORKER_NAME, 10 | isValidStreamMessage, 11 | StreamData, 12 | } from '../utils'; 13 | 14 | /** 15 | * Worker-side dedicated `WebWorker.postMessage` stream. Designed for use with 16 | * dedicated workers only. 17 | */ 18 | export class WebWorkerPostMessageStream extends BasePostMessageStream { 19 | private _name: string; 20 | 21 | constructor(streamOptions: DuplexOptions = {}) { 22 | // Kudos: https://stackoverflow.com/a/18002694 23 | if ( 24 | typeof self === 'undefined' || 25 | // @ts-expect-error: No types for WorkerGlobalScope 26 | typeof WorkerGlobalScope === 'undefined' 27 | ) { 28 | throw new Error( 29 | 'WorkerGlobalScope not found. This class should only be instantiated in a WebWorker.', 30 | ); 31 | } 32 | 33 | super(streamOptions); 34 | 35 | this._name = DEDICATED_WORKER_NAME; 36 | self.addEventListener('message', this._onMessage.bind(this) as any); 37 | 38 | this._handshake(); 39 | } 40 | 41 | protected _postMessage(data: StreamData): void { 42 | // Cast of self.postMessage due to usage of DOM lib 43 | (self.postMessage as (message: any) => void)({ data }); 44 | } 45 | 46 | private _onMessage(event: PostMessageEvent): void { 47 | const message = event.data; 48 | 49 | // validate message 50 | if (!isValidStreamMessage(message) || message.target !== this._name) { 51 | return; 52 | } 53 | 54 | this._onData(message.data); 55 | } 56 | 57 | // worker stream lifecycle assumed to be coterminous with global scope 58 | _destroy(): void { 59 | return undefined; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/window/WindowPostMessageStream.test.ts: -------------------------------------------------------------------------------- 1 | import { WindowPostMessageStream } from './WindowPostMessageStream'; 2 | 3 | describe('WindowPostMessageStream', () => { 4 | it('can override base stream options', () => { 5 | const pms = new WindowPostMessageStream({ 6 | name: 'foo', 7 | target: 'bar', 8 | encoding: 'ucs2', 9 | objectMode: false, 10 | }); 11 | expect(pms._readableState.encoding).toBe('ucs2'); 12 | expect(pms._readableState.objectMode).toBe(false); 13 | expect(pms._writableState.objectMode).toBe(false); 14 | }); 15 | 16 | it('can be instantiated with default options', () => { 17 | const pms = new WindowPostMessageStream({ name: 'foo', target: 'bar' }); 18 | expect(pms._readableState.objectMode).toBe(true); 19 | expect(pms._writableState.objectMode).toBe(true); 20 | }); 21 | 22 | it('throws if window.postMessage is not a function', () => { 23 | const originalPostMessage = window.postMessage; 24 | (window as any).postMessage = undefined; 25 | expect( 26 | () => new WindowPostMessageStream({ name: 'foo', target: 'bar' }), 27 | ).toThrow( 28 | 'window.postMessage is not a function. This class should only be instantiated in a Window.', 29 | ); 30 | (window as any).postMessage = originalPostMessage; 31 | }); 32 | 33 | it('can communicate between windows and be destroyed', async () => { 34 | // Initialize sender stream 35 | const streamA = new WindowPostMessageStream({ 36 | name: 'a', 37 | target: 'b', 38 | }); 39 | 40 | // Prevent stream A from receiving stream B's synchronization message, to 41 | // force execution down a particular path for coverage purposes. 42 | const originalStreamAListener = (streamA as any)._onMessage; 43 | const streamAListener = (event: MessageEvent) => { 44 | if (event.data.data === 'SYN') { 45 | return undefined; 46 | } 47 | return originalStreamAListener(event); 48 | }; 49 | window.removeEventListener('message', originalStreamAListener, false); 50 | window.addEventListener('message', streamAListener, false); 51 | 52 | // Initialize receiver stream. Multiplies incoming values by 5 and 53 | // returns them. 54 | const streamB = new WindowPostMessageStream({ 55 | name: 'b', 56 | target: 'a', 57 | // This shouldn't make a difference, it's just for coverage purposes 58 | targetWindow: window, 59 | }); 60 | streamB.on('data', (value) => streamB.write(value * 5)); 61 | 62 | // Get a deferred Promise for the result 63 | const responsePromise = new Promise((resolve) => { 64 | streamA.once('data', (num) => { 65 | resolve(Number(num)); 66 | }); 67 | }); 68 | 69 | // Write to stream A, triggering a response from stream B 70 | streamA.write(111); 71 | 72 | expect(await responsePromise).toBe(555); 73 | 74 | // Check that events without e.g. the correct event.source are ignored as 75 | // expected 76 | const throwingListener = (data: any) => { 77 | throw new Error(`Unexpected data on stream: ${data}`); 78 | }; 79 | streamA.once('data', throwingListener); 80 | streamB.once('data', throwingListener); 81 | window.dispatchEvent(new Event('message')); 82 | 83 | // Destroy streams and confirm that they were destroyed 84 | streamA.destroy(); 85 | streamB.destroy(); 86 | expect(streamA.destroyed).toBe(true); 87 | expect(streamB.destroyed).toBe(true); 88 | }); 89 | 90 | it('can take targetOrigin as an option', () => { 91 | const stream = new WindowPostMessageStream({ 92 | name: 'foo', 93 | target: 'target', 94 | targetOrigin: '*', 95 | }); 96 | expect((stream as any)._targetOrigin).toBe('*'); 97 | stream.destroy(); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/window/WindowPostMessageStream.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@metamask/utils'; 2 | import type { DuplexOptions } from 'readable-stream'; 3 | import { 4 | BasePostMessageStream, 5 | PostMessageEvent, 6 | } from '../BasePostMessageStream'; 7 | import { isValidStreamMessage } from '../utils'; 8 | 9 | export interface WindowPostMessageStreamArgs extends DuplexOptions { 10 | name: string; 11 | target: string; 12 | targetOrigin?: string; 13 | targetWindow?: Window; 14 | } 15 | 16 | /* istanbul ignore next */ 17 | const getSource = Object.getOwnPropertyDescriptor( 18 | MessageEvent.prototype, 19 | 'source', 20 | )?.get; 21 | assert(getSource, 'MessageEvent.prototype.source getter is not defined.'); 22 | 23 | /* istanbul ignore next */ 24 | const getOrigin = Object.getOwnPropertyDescriptor( 25 | MessageEvent.prototype, 26 | 'origin', 27 | )?.get; 28 | assert(getOrigin, 'MessageEvent.prototype.origin getter is not defined.'); 29 | 30 | /** 31 | * A {@link Window.postMessage} stream. 32 | */ 33 | export class WindowPostMessageStream extends BasePostMessageStream { 34 | private _name: string; 35 | 36 | private _target: string; 37 | 38 | private _targetOrigin: string; 39 | 40 | private _targetWindow: Window; 41 | 42 | /** 43 | * Creates a stream for communicating with other streams across the same or 44 | * different `window` objects. 45 | * 46 | * @param args - Options bag. 47 | * @param args.name - The name of the stream. Used to differentiate between 48 | * multiple streams sharing the same window object. 49 | * @param args.target - The name of the stream to exchange messages with. 50 | * @param args.targetOrigin - The origin of the target. Defaults to 51 | * `location.origin`, '*' is permitted. 52 | * @param args.targetWindow - The window object of the target stream. Defaults 53 | * to `window`. 54 | */ 55 | constructor({ 56 | name, 57 | target, 58 | targetOrigin = location.origin, 59 | targetWindow = window, 60 | ...streamOptions 61 | }: WindowPostMessageStreamArgs) { 62 | super(streamOptions); 63 | 64 | if ( 65 | typeof window === 'undefined' || 66 | typeof window.postMessage !== 'function' 67 | ) { 68 | throw new Error( 69 | 'window.postMessage is not a function. This class should only be instantiated in a Window.', 70 | ); 71 | } 72 | 73 | this._name = name; 74 | this._target = target; 75 | this._targetOrigin = targetOrigin; 76 | this._targetWindow = targetWindow; 77 | this._onMessage = this._onMessage.bind(this); 78 | 79 | window.addEventListener('message', this._onMessage as any, false); 80 | 81 | this._handshake(); 82 | } 83 | 84 | protected _postMessage(data: unknown): void { 85 | this._targetWindow.postMessage( 86 | { 87 | target: this._target, 88 | data, 89 | }, 90 | this._targetOrigin, 91 | ); 92 | } 93 | 94 | private _onMessage(event: PostMessageEvent): void { 95 | const message = event.data; 96 | 97 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 98 | if ( 99 | (this._targetOrigin !== '*' && 100 | getOrigin!.call(event) !== this._targetOrigin) || 101 | getSource!.call(event) !== this._targetWindow || 102 | !isValidStreamMessage(message) || 103 | message.target !== this._name 104 | ) { 105 | return; 106 | } 107 | /* eslint-enable @typescript-eslint/no-non-null-assertion */ 108 | 109 | this._onData(message.data); 110 | } 111 | 112 | _destroy(): void { 113 | window.removeEventListener('message', this._onMessage as any, false); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "inlineSources": true, 6 | "noEmit": false, 7 | "outDir": "dist", 8 | "rootDir": "src", 9 | "sourceMap": true 10 | }, 11 | "include": ["./src/**/*.ts"], 12 | "exclude": ["./src/**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "lib": ["DOM", "ES2020"], 6 | "module": "CommonJS", 7 | "moduleResolution": "node", 8 | "noEmit": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "es2017" 12 | }, 13 | "exclude": ["./dist/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "excludePrivate": true, 4 | "hideGenerator": true, 5 | "out": "docs" 6 | } 7 | --------------------------------------------------------------------------------