diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
deleted file mode 100644
index d724f86b48..0000000000
--- a/.github/workflows/cla.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: "CLA Assistant"
-on:
- issue_comment:
- types: [created]
- pull_request_target:
- types: [opened,closed,synchronize]
-
-permissions:
- actions: write
- contents: read
- pull-requests: write
- statuses: write
-
-jobs:
- CLAAssistant:
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- - name: "CLA Assistant"
- if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
- uses: contributor-assistant/github-action@v2.6.1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_GH_PAT }}
- with:
- path-to-signatures: 'signatures/browser/version1/cla.json'
- path-to-document: 'https://github.com/lightpanda-io/browser/blob/main/CLA.md'
- # branch should not be protected
- branch: 'main'
- allowlist: krichprollsch,francisbouvier,katie-lpd,sjorsdonkers,bornlex
-
- remote-organization-name: lightpanda-io
- remote-repository-name: cla
diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml
deleted file mode 100644
index 3c66f5c8d0..0000000000
--- a/.github/workflows/e2e-integration-test.yml
+++ /dev/null
@@ -1,79 +0,0 @@
-name: e2e-integration-test
-
-env:
- LIGHTPANDA_DISABLE_TELEMETRY: true
-
-on:
- schedule:
- - cron: "4 4 * * *"
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-jobs:
- zig-build-release:
- name: zig build release
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- # Don't run the CI with draft PR.
- if: github.event.pull_request.draft == false
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
-
- - name: zig build release
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
-
- - name: upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: lightpanda-build-release
- path: |
- zig-out/bin/lightpanda
- retention-days: 1
-
- demo-scripts:
- name: demo-integration-scripts
- needs: zig-build-release
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - run: npm install
-
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- - name: run end to end integration tests
- continue-on-error: true
- run: |
- ./lightpanda serve --log-level error & echo $! > LPD.pid
- go run integration/main.go |tee result.log
- kill `cat LPD.pid`
-
- - name: Send result to slack
- uses: slackapi/slack-github-action@v3.0.1
- with:
- errors: true
- method: files.uploadV2
- token: ${{ secrets.CI_SLACK_BOT_TOKEN }}
- payload: |
- channel_id: ${{ vars.E2E_SLACK_CHANNEL_ID }}
- initial_comment: "Last e2e integration tests"
- file: "./result.log"
- filename: "e2e-integration-${{ github.sha }}.txt"
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
deleted file mode 100644
index a47829bca7..0000000000
--- a/.github/workflows/e2e-test.yml
+++ /dev/null
@@ -1,390 +0,0 @@
-name: e2e-test
-
-env:
- AWS_ACCESS_KEY_ID: ${{ vars.LPD_PERF_AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
- AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
- LIGHTPANDA_DISABLE_TELEMETRY: true
-
-on:
- push:
- branches: [main]
- paths:
- - ".github/**"
- - "src/**"
- - "build.zig"
- - "build.zig.zon"
-
- pull_request:
-
- # By default GH trigger on types opened, synchronize and reopened.
- # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
- # Since we skip the job when the PR is in draft state, we want to force CI
- # running when the PR is marked ready_for_review w/o other change.
- # see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
- types: [opened, synchronize, reopened, ready_for_review]
-
- paths:
- - ".github/**"
- - "src/**"
- - "build.zig"
- - "build.zig.zon"
-
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-jobs:
- zig-build-release:
- name: zig build release
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- # Don't run the CI with draft PR.
- if: github.event.pull_request.draft == false
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
-
- - name: zig build release
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
-
- - name: upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: lightpanda-build-release
- path: |
- zig-out/bin/lightpanda
- retention-days: 1
-
- demo-scripts:
- name: demo-scripts
- needs: zig-build-release
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - run: npm install
-
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- - name: run end to end tests
- run: |
- ./lightpanda serve & echo $! > LPD.pid
- go run runner/main.go
- kill `cat LPD.pid`
-
- - name: build proxy
- run: |
- cd proxy
- go build
-
- - name: run end to end tests through proxy
- run: |
- ./proxy/proxy & echo $! > PROXY.id
- ./lightpanda serve --http-proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
- go run runner/main.go
- URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
- kill `cat LPD.pid` `cat PROXY.id`
-
- - name: run request interception through proxy and playwright
- run: |
- export PROXY_USERNAME=username PROXY_PASSWORD=password
- ./proxy/proxy & echo $! > PROXY.id
- ./lightpanda serve & echo $! > LPD.pid
- BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
- kill `cat LPD.pid` `cat PROXY.id`
-
- # e2e tests w/ web-bot-auth configuration on.
- wba-demo-scripts:
- name: wba-demo-scripts
- needs: zig-build-release
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - run: npm install
-
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
-
- - name: run end to end tests
- run: |
- ./lightpanda serve \
- --web-bot-auth-key-file private_key.pem \
- --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
- --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
- & echo $! > LPD.pid
- go run runner/main.go
- kill `cat LPD.pid`
-
- - name: build proxy
- run: |
- cd proxy
- go build
-
- - name: run end to end tests through proxy
- run: |
- ./proxy/proxy & echo $! > PROXY.id
- ./lightpanda serve \
- --web-bot-auth-key-file private_key.pem \
- --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
- --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
- --http-proxy 'http://127.0.0.1:3000' \
- & echo $! > LPD.pid
- go run runner/main.go
- URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
- kill `cat LPD.pid` `cat PROXY.id`
-
- - name: run request interception through proxy and playwright
- run: |
- export PROXY_USERNAME=username PROXY_PASSWORD=password
- ./proxy/proxy & echo $! > PROXY.id
- ./lightpanda serve \
- --web-bot-auth-key-file private_key.pem \
- --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
- --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
- & echo $! > LPD.pid
- BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
- kill `cat LPD.pid` `cat PROXY.id`
-
- wba-test:
- name: wba-test
- needs: zig-build-release
-
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- # Don't execute on PR
- if: github.event_name != 'pull_request'
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- # force a wakup of the auth server before requesting it w/ the test itself
- - run: curl https://${{ vars.WBA_DOMAIN }}
-
- - name: run wba test
- shell: bash
- run: |
- node webbotauth/validator.js &
- VALIDATOR_PID=$!
- sleep 5
-
- exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
-
- ./lightpanda fetch --dump http://127.0.0.1:8989/ \
- --web-bot-auth-key-file /proc/self/fd/3 \
- --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
- --web-bot-auth-domain ${{ vars.WBA_DOMAIN }}
-
- wait $VALIDATOR_PID
- exec 3>&-
-
- cdp-and-hyperfine-bench:
- name: cdp-and-hyperfine-bench
- needs: zig-build-release
-
- env:
- MAX_VmHWM: 28000 # 28MB (KB)
- MAX_CG_PEAK: 8000 # 8MB (KB)
- MAX_AVG_DURATION: 17
-
- # How to give cgroups access to the user actions-runner on the host:
- # $ sudo apt install cgroup-tools
- # $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs
- # $ sudo mkdir -p /sys/fs/cgroup/actions-runner
- # $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner
- CG_ROOT: /sys/fs/cgroup
- CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }}
-
- # use a self host runner.
- runs-on: lpd-bench-hetzner
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - run: npm install
-
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- - name: start http
- run: |
- go run ws/main.go & echo $! > WS.pid
- sleep 2
-
- - name: run lightpanda in cgroup
- run: |
- if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then
- echo "cgroup v2 not available: /sys/fs/cgroup/cgroup.controllers missing"
- exit 1
- fi
-
- mkdir -p $CG_ROOT/$CG
- cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid
-
- sleep 2
-
- - name: run puppeteer
- run: |
- RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
- cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
- kill `cat LPD.pid`
-
- PID=$(cat LPD.pid)
- while kill -0 $PID 2>/dev/null; do
- sleep 1
- done
- if [ ! -f $CG_ROOT/$CG/memory.peak ]; then
- echo "memory.peak not available in $CG"
- exit 1
- fi
- cat $CG_ROOT/$CG/memory.peak > LPD.cg_mem_peak
-
- - name: puppeteer result
- run: cat puppeteer.out
-
- - name: cgroup memory regression
- run: |
- PEAK_BYTES=$(cat LPD.cg_mem_peak)
- PEAK_KB=$((PEAK_BYTES / 1024))
- echo "memory.peak_bytes=$PEAK_BYTES"
- echo "memory.peak_kb=$PEAK_KB"
- test "$PEAK_KB" -le "$MAX_CG_PEAK"
-
- - name: virtual memory regression
- run: |
- export LPD_VmHWM=`cat LPD.VmHWM`
- echo "Peak resident set size: $LPD_VmHWM"
- test "$LPD_VmHWM" -le "$MAX_VmHWM"
-
- - name: cleanup cgroup
- run: rmdir $CG_ROOT/$CG
-
- - name: duration regression
- run: |
- export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
- echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
- test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
-
- - name: json output
- run: |
- export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
- export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'`
- export LPD_VmHWM=`cat LPD.VmHWM`
- export LPD_CG_PEAK_KB=$(( $(cat LPD.cg_mem_peak) / 1024 ))
- echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM},\"cg_mem_peak\":${LPD_CG_PEAK_KB}}" > bench.json
- cat bench.json
-
- - name: run hyperfine
- run: |
- hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump http://127.0.0.1:1234/campfire-commerce/"
-
- - name: stop http
- run: kill `cat WS.pid`
-
- - name: write commit
- run: |
- echo "${{github.sha}}" > commit.txt
-
- - name: upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: bench-results
- path: |
- bench.json
- hyperfine.json
- commit.txt
- retention-days: 10
-
-
- perf-fmt:
- name: perf-fmt
- needs: cdp-and-hyperfine-bench
-
- # Don't execute on PR
- if: github.event_name != 'pull_request'
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- container:
- image: ghcr.io/lightpanda-io/perf-fmt:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- steps:
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: bench-results
-
- - name: format and send json result
- run: /perf-fmt cdp ${{ github.sha }} bench.json
-
- - name: format and send json result
- run: /perf-fmt hyperfine ${{ github.sha }} hyperfine.json
-
- browser-fetch:
- name: browser fetch
- needs: zig-build-release
-
- runs-on: ubuntu-latest
-
- steps:
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- - run: ./lightpanda fetch https://demo-browser.lightpanda.io/campfire-commerce/
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
deleted file mode 100644
index cc5aed83f9..0000000000
--- a/.github/workflows/nightly.yml
+++ /dev/null
@@ -1,186 +0,0 @@
-name: nightly build
-
-env:
- AWS_ACCESS_KEY_ID: ${{ vars.NIGHTLY_BUILD_AWS_ACCESS_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
- AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
-
- RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
- VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion={0}', github.ref_name) || '-Dversion=nightly' }}
-
-on:
- push:
- tags:
- - '*'
- schedule:
- - cron: "2 2 * * *"
-
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-permissions:
- contents: write
-
-jobs:
- build-linux-x86_64:
- env:
- ARCH: x86_64
- OS: linux
-
- runs-on: ubuntu-22.04
- timeout-minutes: 20
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
- with:
- os: ${{env.OS}}
- arch: ${{env.ARCH}}
-
- - name: v8 snapshot
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
-
- - name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
-
- - name: Rename binary
- run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: upload on s3
- run: |
- export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
- aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: Upload the build
- uses: ncipollo/release-action@v1
- with:
- allowUpdates: true
- artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
- tag: ${{ env.RELEASE }}
- makeLatest: true
-
- build-linux-aarch64:
- env:
- ARCH: aarch64
- OS: linux
-
- runs-on: ubuntu-22.04-arm
- timeout-minutes: 20
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
- with:
- os: ${{env.OS}}
- arch: ${{env.ARCH}}
-
- - name: v8 snapshot
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
-
- - name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
-
- - name: Rename binary
- run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: upload on s3
- run: |
- export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
- aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: Upload the build
- uses: ncipollo/release-action@v1
- with:
- allowUpdates: true
- artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
- tag: ${{ env.RELEASE }}
- makeLatest: true
-
- build-macos-aarch64:
- env:
- ARCH: aarch64
- OS: macos
-
- # macos-14 runs on arm CPU. see
- # https://github.com/actions/runner-images?tab=readme-ov-file
- runs-on: macos-14
- timeout-minutes: 20
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
- with:
- os: ${{env.OS}}
- arch: ${{env.ARCH}}
-
- - name: v8 snapshot
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
-
- - name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
-
- - name: Rename binary
- run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: upload on s3
- run: |
- export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
- aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: Upload the build
- uses: ncipollo/release-action@v1
- with:
- allowUpdates: true
- artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
- tag: ${{ env.RELEASE }}
- makeLatest: true
-
- build-macos-x86_64:
- env:
- ARCH: x86_64
- OS: macos
-
- runs-on: macos-14-large
- timeout-minutes: 20
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
- with:
- os: ${{env.OS}}
- arch: ${{env.ARCH}}
-
- - name: v8 snapshot
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
-
- - name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
-
- - name: Rename binary
- run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: upload on s3
- run: |
- export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
- aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
-
- - name: Upload the build
- uses: ncipollo/release-action@v1
- with:
- allowUpdates: true
- artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
- tag: ${{ env.RELEASE }}
- makeLatest: true
diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml
deleted file mode 100644
index c6cc735074..0000000000
--- a/.github/workflows/wpt.yml
+++ /dev/null
@@ -1,186 +0,0 @@
-name: wpt
-
-env:
- AWS_ACCESS_KEY_ID: ${{ vars.LPD_PERF_AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
- AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
- AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
- LIGHTPANDA_DISABLE_TELEMETRY: true
-
-on:
- schedule:
- - cron: "21 2 * * *"
-
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-jobs:
- wpt-build-release:
- name: zig build release
-
- env:
- ARCH: aarch64
- OS: linux
-
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 20
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/install
- with:
- os: ${{env.OS}}
- arch: ${{env.ARCH}}
-
- - name: v8 snapshot
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
-
- - name: zig build release
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic
-
- - name: upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: lightpanda-build-release
- path: |
- zig-out/bin/lightpanda
- retention-days: 1
-
- wpt-build-runner:
- name: build wpt runner
-
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - run: |
- cd ./wptrunner
- CGO_ENABLED=0 go build
-
- - name: upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: wptrunner
- path: |
- wptrunner/wptrunner
- retention-days: 1
-
- run-wpt:
- name: web platform tests json output
- needs:
- - wpt-build-release
- - wpt-build-runner
-
- # use a self host runner.
- runs-on: lpd-wpt-aws
- timeout-minutes: 600
-
- steps:
- - uses: actions/checkout@v6
- with:
- ref: fork
- repository: 'lightpanda-io/wpt'
- fetch-depth: 0
-
- # The hosts are configured manually on the self host runner.
- # - name: create custom hosts
- # run: ./wpt make-hosts-file | sudo tee -a /etc/hosts
-
- - name: generate manifest
- run: ./wpt manifest
-
- - name: download lightpanda release
- uses: actions/download-artifact@v8
- with:
- name: lightpanda-build-release
-
- - run: chmod a+x ./lightpanda
-
- - name: download wptrunner
- uses: actions/download-artifact@v8
- with:
- name: wptrunner
-
- - run: chmod a+x ./wptrunner
-
- - name: run test with json output
- run: |
- ./wpt serve 2> /dev/null & echo $! > WPT.pid
- sleep 20s
- ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
- kill `cat WPT.pid`
-
- - name: write commit
- run: |
- echo "${{github.sha}}" > commit.txt
-
- - name: upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: wpt-results
- path: |
- wpt.json
- commit.txt
- retention-days: 10
-
- perf-fmt:
- name: perf-fmt
- needs: run-wpt
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- container:
- image: ghcr.io/lightpanda-io/perf-fmt:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- steps:
- - name: download artifact
- uses: actions/download-artifact@v8
- with:
- name: wpt-results
-
- - name: format and send json result
- run: /perf-fmt wpt ${{ github.sha }} wpt.json
-
- wptdiff:
- name: perf-fmt
- needs: perf-fmt
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v6
- with:
- repository: 'lightpanda-io/demo'
- fetch-depth: 0
-
- - run: |
- cd ./wptdiff
- CGO_ENABLED=0 go build
-
- - run: |
- ./wptdiff/wptdiff |tee diff.log
-
- - name: Send regression to slack
- uses: slackapi/slack-github-action@v3.0.1
- with:
- errors: true
- method: files.uploadV2
- token: ${{ secrets.CI_SLACK_BOT_TOKEN }}
- payload: |
- channel_id: ${{ vars.WPT_SLACK_CHANNEL_ID }}
- initial_comment: "Last WPT regressions"
- file: "./diff.log"
- filename: "wpt-regression-${{ github.sha }}.txt"
diff --git a/CLA.md b/CLA.md
deleted file mode 100644
index 36a4973be8..0000000000
--- a/CLA.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Lightpanda (Selecy SAS) Grant and Contributor License Agreement (“Agreement”)
-
-This agreement is based on the Apache Software Foundation Contributor License
-Agreement. (v r190612)
-
-Thank you for your interest in software projects stewarded by Lightpanda
-(Selecy SAS) (“Lightpanda”). In order to clarify the intellectual property
-license granted with Contributions from any person or entity, Lightpanda must
-have a Contributor License Agreement (CLA) on file that has been agreed to by
-each Contributor, indicating agreement to the license terms below. This license
-is for your protection as a Contributor as well as the protection of Lightpanda
-and its users; it does not change your rights to use your own Contributions for
-any other purpose. This Agreement allows an individual to contribute to
-Lightpanda on that individual’s own behalf, or an entity (the “Corporation”) to
-submit Contributions to Lightpanda, to authorize Contributions submitted by its
-designated employees to Lightpanda, and to grant copyright and patent licenses
-thereto.
-
-You accept and agree to the following terms and conditions for Your present and
-future Contributions submitted to Lightpanda. Except for the license granted
-herein to Lightpanda and recipients of software distributed by Lightpanda, You
-reserve all right, title, and interest in and to Your Contributions.
-
-1. Definitions. “You” (or “Your”) shall mean the copyright owner or legal
- entity authorized by the copyright owner that is making this Agreement with
- Lightpanda. For legal entities, the entity making a Contribution and all
- other entities that control, are controlled by, or are under common control
- with that entity are considered to be a single Contributor. For the purposes
- of this definition, “control” means (i) the power, direct or indirect, to
- cause the direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
- “Contribution” shall mean any work, as well as any modifications or
- additions to an existing work, that is intentionally submitted by You to
- Lightpanda for inclusion in, or documentation of, any of the products owned
- or managed by Lightpanda (the “Work”). For the purposes of this definition,
- “submitted” means any form of electronic, verbal, or written communication
- sent to Lightpanda or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems (such
- as GitHub), and issue tracking systems that are managed by, or on behalf of,
- Lightpanda for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise designated
- in writing by You as “Not a Contribution.”
-
-2. Grant of Copyright License. Subject to the terms and conditions of this
- Agreement, You hereby grant to Lightpanda and to recipients of software
- distributed by Lightpanda a perpetual, worldwide, non-exclusive, no-charge,
- royalty-free, irrevocable copyright license to reproduce, prepare derivative
- works of, publicly display, publicly perform, sublicense, and distribute
- Your Contributions and such derivative works.
-
-3. Grant of Patent License. Subject to the terms and conditions of this
- Agreement, You hereby grant to Lightpanda and to recipients of software
- distributed by Lightpanda a perpetual, worldwide, non-exclusive, no-charge,
- royalty-free, irrevocable (except as stated in this section) patent license
- to make, have made, use, offer to sell, sell, import, and otherwise transfer
- the Work, where such license applies only to those patent claims licensable
- by You that are necessarily infringed by Your Contribution(s) alone or by
- combination of Your Contribution(s) with the Work to which such
- Contribution(s) were submitted. If any entity institutes patent litigation
- against You or any other entity (including a cross-claim or counterclaim in
- a lawsuit) alleging that your Contribution, or the Work to which you have
- contributed, constitutes direct or contributory patent infringement, then
- any patent licenses granted to that entity under this Agreement for that
- Contribution or Work shall terminate as of the date such litigation is
- filed.
-
-4. You represent that You are legally entitled to grant the above license. If
- You are an individual, and if Your employer(s) has rights to intellectual
- property that you create that includes Your Contributions, you represent
- that You have received permission to make Contributions on behalf of that
- employer, or that Your employer has waived such rights for your
- Contributions to Lightpanda. If You are a Corporation, any individual who
- makes a contribution from an account associated with You will be considered
- authorized to Contribute on Your behalf.
-
-5. You represent that each of Your Contributions is Your original creation (see
- section 7 for submissions on behalf of others).
-
-6. You are not expected to provide support for Your Contributions,except to the
- extent You desire to provide support. You may provide support for free, for
- a fee, or not at all. Unless required by applicable law or agreed to in
- writing, You provide Your Contributions on an “AS IS” BASIS, WITHOUT
- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
- without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT,
- MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
-
-7. Should You wish to submit work that is not Your original creation, You may
- submit it to Lightpanda separately from any Contribution, identifying the
- complete details of its source and of any license or other restriction
- (including, but not limited to, related patents, trademarks, and license
- agreements) of which you are personally aware, and conspicuously marking the
- work as “Submitted on behalf of a third-party: [named here]”.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 351d63e568..0000000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Contributing
-
-Lightpanda accepts pull requests through GitHub.
-
-You have to sign our [CLA](CLA.md) during your first pull request process
-otherwise we're not able to accept your contributions.
-
-The process signature uses the [CLA assistant
-lite](https://github.com/marketplace/actions/cla-assistant-lite). You can see
-an example of the process in [#303](https://github.com/lightpanda-io/browser/pull/303).
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index b266f9164e..0000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,77 +0,0 @@
-FROM debian:stable-slim
-
-ARG MINISIG=0.12
-ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
-ARG V8=14.0.365.4
-ARG ZIG_V8=v0.3.7
-ARG TARGETPLATFORM
-
-RUN apt-get update -yq && \
- apt-get install -yq xz-utils ca-certificates \
- pkg-config libglib2.0-dev \
- clang make curl git
-
-# Get Rust
-RUN curl https://sh.rustup.rs -sSf | sh -s -- --profile minimal -y
-ENV PATH="/root/.cargo/bin:${PATH}"
-
-# install minisig
-RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${MINISIG}/minisign-${MINISIG}-linux.tar.gz && \
- tar xvzf minisign-${MINISIG}-linux.tar.gz -C /
-
-# clone lightpanda
-RUN git clone https://github.com/lightpanda-io/browser.git
-WORKDIR /browser
-
-# install zig
-RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \
- case $TARGETPLATFORM in \
- "linux/arm64") ARCH="aarch64" ;; \
- *) ARCH="x86_64" ;; \
- esac && \
- curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz && \
- curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig && \
- /minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \
- tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \
- mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
- ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
-
-# download and install v8
-RUN case $TARGETPLATFORM in \
- "linux/arm64") ARCH="aarch64" ;; \
- *) ARCH="x86_64" ;; \
- esac && \
- curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
- mkdir -p v8/ && \
- mv libc_v8.a v8/libc_v8.a
-
-# build v8 snapshot
-RUN zig build -Doptimize=ReleaseFast \
- -Dprebuilt_v8_path=v8/libc_v8.a \
- snapshot_creator -- src/snapshot.bin
-
-# build release
-RUN zig build -Doptimize=ReleaseFast \
- -Dsnapshot_path=../../snapshot.bin \
- -Dprebuilt_v8_path=v8/libc_v8.a
-
-FROM debian:stable-slim
-
-RUN apt-get update -yq && \
- apt-get install -yq tini
-
-FROM debian:stable-slim
-
-# copy ca certificates
-COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
-
-COPY --from=0 /browser/zig-out/bin/lightpanda /bin/lightpanda
-COPY --from=1 /usr/bin/tini /usr/bin/tini
-
-EXPOSE 9222/tcp
-
-# Lightpanda install only some signal handlers, and PID 1 doesn't have a default SIGTERM signal handler.
-# Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang.
-# (See https://github.com/krallin/tini#why-tini).
-ENTRYPOINT ["/usr/bin/tini", "--"]
-CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log-level", "info"]
diff --git a/LICENSING.md b/LICENSING.md
deleted file mode 100644
index 7d40243e63..0000000000
--- a/LICENSING.md
+++ /dev/null
@@ -1,6 +0,0 @@
-# Licensing
-
-License names used in this document are as per [SPDX License
-List](https://spdx.org/licenses/).
-
-The default license for this project is [AGPL-3.0-only](LICENSE).
diff --git a/README.md b/README.md
index feba515c3f..bc12c0861a 100644
--- a/README.md
+++ b/README.md
@@ -1,375 +1,123 @@
-
-
-
-Lightpanda Browser
-
-The headless browser built from scratch for AI agents and automation.
-Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
-
-
-
-[](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
-[](https://twitter.com/lightpanda_io)
-[](https://github.com/lightpanda-io/browser)
-[](https://discord.gg/K63XeymfB5)
-
-
-
-
-[

-](https://github.com/lightpanda-io/demo)
-
-[

-](https://github.com/lightpanda-io/demo)
-
-
-_chromedp requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
-See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark)._
-
-Lightpanda is the open-source browser made for headless usage:
-
-- Javascript execution
-- Support of Web APIs (partial, WIP)
-- Compatible with Playwright[^1], Puppeteer, chromedp through [CDP](https://chromedevtools.github.io/devtools-protocol/)
-
-Fast web automation for AI agents, LLM training, scraping and testing:
-
-- Ultra-low memory footprint (16x less than Chrome)
-- Exceptionally fast execution (9x faster than Chrome)
-- Instant startup
-
-[^1]: **Playwright support disclaimer:**
-Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
+
-## Quick start
+**Make Lightpanda invisible to anti-bot detection.**
-### Install
-**Install from the nightly builds**
+[](LICENSE)
+[](https://github.com/we-be/stealthpanda)
+[](https://ziglang.org)
-You can download the last binary from the [nightly
-builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
-Linux x86_64 and MacOS aarch64.
+| Memory | Execution | Fingerprint | Turnstile |
+|:--:|:--:|:--:|:--:|
+| **16x less than Chrome** | **9x faster** | **Chrome 131** | **WIP** |
-*For Linux*
-```console
-curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && \
-chmod a+x ./lightpanda
-```
-
-*For MacOS*
-```console
-curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \
-chmod a+x ./lightpanda
-```
-
-*For Windows + WSL2*
+
-The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal.
-It is recommended to install clients like Puppeteer on the Windows host.
+---
-**Install from Docker**
+Fork of [Lightpanda](https://github.com/lightpanda-io/browser) that patches its JS fingerprint to match Chrome 131. Same speed and memory advantages, none of the bot signals.
-Lightpanda provides [official Docker
-images](https://hub.docker.com/r/lightpanda/browser) for both Linux amd64 and
-arm64 architectures.
-The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port `9222`.
-```console
-docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
```
-
-### Dump a URL
-
-```console
-./lightpanda fetch --obey-robots --log-format pretty --log-level info https://demo-browser.lightpanda.io/campfire-commerce/
+ Lightpanda StealthPanda
+ ┌──────────────────┐ ┌──────────────────┐
+ │ Fast & light │ │ Fast & light │
+ │ JS via V8 │ │ JS via V8 │
+ │ CDP compatible │ │ CDP compatible │
+ │ │ │ │
+ │ ✗ Bot signals │ ──────▶ │ ✓ Chrome UA │
+ │ ✗ Empty plugins │ │ ✓ 5 PDF plugins │
+ │ ✗ No canvas │ │ ✓ Canvas stub │
+ │ ✗ No audio ctx │ │ ✓ AudioContext │
+ │ ✗ 0x0 screen │ │ ✓ 1920x1080 │
+ └──────────────────┘ └──────────────────┘
```
-```console
-INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
- disabled = false
-
-INFO page : navigate . . . . . . . . . . . . . . . . . . . . [+6ms]
- url = https://demo-browser.lightpanda.io/campfire-commerce/
- method = GET
- reason = address_bar
- body = false
- req_id = 1
-
-INFO browser : executing script . . . . . . . . . . . . . . [+118ms]
- src = https://demo-browser.lightpanda.io/campfire-commerce/script.js
- kind = javascript
- cacheable = true
-
-INFO http : request complete . . . . . . . . . . . . . . . . [+140ms]
- source = xhr
- url = https://demo-browser.lightpanda.io/campfire-commerce/json/product.json
- status = 200
- len = 4770
-
-INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
- source = fetch
- url = https://demo-browser.lightpanda.io/campfire-commerce/json/reviews.json
- status = 200
- len = 1615
-
-```
-
-### Start a CDP server
-```console
-./lightpanda serve --obey-robots --log-format pretty --log-level info --host 127.0.0.1 --port 9222
-```
-```console
-INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
- disabled = false
+## Quick Start
-INFO app : server running . . . . . . . . . . . . . . . . . [+0ms]
- address = 127.0.0.1:9222
+```bash
+git clone https://github.com/we-be/stealthpanda.git && cd stealthpanda
+make build-dev
+./zig-out/bin/lightpanda serve --host 127.0.0.1 --port 9222
```
-Once the CDP server started, you can run a Puppeteer script by configuring the
-`browserWSEndpoint`.
+Connect with Puppeteer:
```js
-'use strict'
-
import puppeteer from 'puppeteer-core';
-// use browserWSEndpoint to pass the Lightpanda's CDP server address.
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://127.0.0.1:9222",
});
-// The rest of your script remains the same.
-const context = await browser.createBrowserContext();
-const page = await context.newPage();
-
-// Dump all the links from the page.
-await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
-
-const links = await page.evaluate(() => {
- return Array.from(document.querySelectorAll('a')).map(row => {
- return row.getAttribute('href');
- });
-});
-
-console.log(links);
-
-await page.close();
-await context.close();
-await browser.disconnect();
+const page = await (await browser.createBrowserContext()).newPage();
+await page.goto('https://example.com');
+// fingerprint matches Chrome 131
```
-### Telemetry
-By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable `LIGHTPANDA_DISABLE_TELEMETRY=true`. You can read Lightpanda's privacy policy at: [https://lightpanda.io/privacy-policy](https://lightpanda.io/privacy-policy).
-
-## Status
+Custom viewport:
-Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work.
-You may still encounter errors or crashes. Please open an issue with specifics if so.
-
-Here are the key features we have implemented:
+```bash
+./zig-out/bin/lightpanda serve --screen-width 1440 --screen-height 900 --host 127.0.0.1 --port 9222
+```
-- [ ] CORS [#2015](https://github.com/lightpanda-io/browser/issues/2015)
-- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
-- [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
-- [x] DOM tree
-- [x] Javascript support ([v8](https://v8.dev/))
-- [x] DOM APIs
-- [x] Ajax
- - [x] XHR API
- - [x] Fetch API
-- [x] DOM dump
-- [x] CDP/websockets server
-- [x] Click
-- [x] Input form
-- [x] Cookies
-- [x] Custom HTTP headers
-- [x] Proxy support
-- [x] Network interception
-- [x] Respect `robots.txt` with option `--obey-robots`
+## Fingerprint Coverage
-NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
+| Signal | Lightpanda | StealthPanda | Phase |
+|--------|-----------|--------------|:-----:|
+| `navigator.userAgent` | `Lightpanda/1.0` | Chrome 131 UA | 0 ✅ |
+| `navigator.vendor` | `""` | `"Google Inc."` | 0 ✅ |
+| `navigator.plugins` | Empty | 5 Chrome PDF plugins | 0 ✅ |
+| `navigator.webdriver` | `true` | `false` | 0 ✅ |
+| `screen` / `window` dimensions | `0 x 0` | Configurable (default 1920x1080) | 0 ✅ |
+| `canvas.toDataURL()` | Throws | Valid PNG data URL | 0 ✅ |
+| `AudioContext` | Missing | Stub (state, sampleRate, baseLatency) | 0 ✅ |
+| TLS fingerprint (JA3/JA4) | Default libcurl | Chrome-matching | 1 |
+| Canvas rendering | Placeholder | Real pixels via z2d | 2 |
+| WebGL fingerprint | Missing | Stub/emulated | 3 |
+| CDP automation signals | Exposed | Masked | 4 |
+| Headed rendering | None | Optional GUI mode | 5 |
-## Build from sources
+## Roadmap
-### Prerequisites
+| Phase | What | Status |
+|:-----:|------|:------:|
+| 0 | Browser identity & API stubs | ✅ |
+| 1 | TLS fingerprint mimicry | Planned |
+| 2 | Canvas rendering (z2d) | Planned |
+| 3 | WebGL fingerprint | Planned |
+| 4 | CDP stealth | Planned |
+| 5 | Headed rendering | Planned |
-Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
-install it with the right version in order to build the project.
+**Turnstile MVP = Phases 0–4.**
-Lightpanda also depends on
-[v8](https://chromium.googlesource.com/v8/v8.git),
-[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
+## Build
-To be able to build the v8 engine, you have to install some libs:
+Requires [Zig](https://ziglang.org/) 0.15.2, [Rust](https://rust-lang.org/tools/install/), and system deps for V8/libcurl/html5ever.
-For **Debian/Ubuntu based Linux**:
+```bash
+# Debian/Ubuntu
+sudo apt install xz-utils ca-certificates pkg-config libglib2.0-dev clang make curl git
-```
-sudo apt install xz-utils ca-certificates \
- pkg-config libglib2.0-dev \
- clang make curl git
-```
-You also need to [install Rust](https://rust-lang.org/tools/install/).
-
-For systems with [**Nix**](https://nixos.org/download/), you can use the devShell:
-```
+# Or Nix
nix develop
-```
-
-For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/).
-```
-brew install cmake
-```
-
-### Build and run
-
-You an build the entire browser with `make build` or `make build-dev` for debug
-env.
-
-But you can directly use the zig command: `zig build run`.
-#### Embed v8 snapshot
-
-Lighpanda uses v8 snapshot. By default, it is created on startup but you can
-embed it by using the following commands:
-
-Generate the snapshot.
-```
-zig build snapshot_creator -- src/snapshot.bin
+make build-dev # debug
+make build # release
+make test # unit tests
```
-Build using the snapshot binary.
-```
-zig build -Dsnapshot_path=../../snapshot.bin
-```
-
-See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details.
-
## Test
-### Unit Tests
-
-You can test Lightpanda by running `make test`.
-
-### End to end tests
-
-To run end to end tests, you need to clone the [demo
-repository](https://github.com/lightpanda-io/demo) into `../demo` dir.
-
-You have to install the [demo's node
-requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1)
-
-You also need to install [Go](https://go.dev) > v1.24.
-
-```
-make end2end
-```
-
-### Web Platform Tests
-
-Lightpanda is tested against the standardized [Web Platform
-Tests](https://web-platform-tests.org/).
-
-We use [a fork](https://github.com/lightpanda-io/wpt/tree/fork) including a custom
-[`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f).
-
-For reference, you can easily execute a WPT test case with your browser via
-[wpt.live](https://wpt.live).
-
-#### Configure WPT HTTP server
-
-To run the test, you must clone the repository, configure the custom hosts and generate the
-`MANIFEST.json` file.
-
-Clone the repository with the `fork` branch.
-```
-git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
-```
-
-Enter into the `wpt/` dir.
-
-Install custom domains in your `/etc/hosts`
-```
-./wpt make-hosts-file | sudo tee -a /etc/hosts
-```
-
-Generate `MANIFEST.json`
-```
-./wpt manifest
-```
-Use the [WPT's setup
-guide](https://web-platform-tests.org/running-tests/from-local-system.html) for
-details.
-
-#### Run WPT test suite
-
-An external [Go](https://go.dev) runner is provided by
-[github.com/lightpanda-io/demo/](https://github.com/lightpanda-io/demo/)
-repository, located into `wptrunner/` dir.
-You need to clone the project first.
-
-First start the WPT's HTTP server from your `wpt/` clone dir.
-```
-./wpt serve
+```bash
+# Fingerprint verification (start CDP server first)
+node test_fingerprint.js
```
-Run a Lightpanda browser
-
-```
-zig build run -- --insecure-disable-tls-host-verification
-```
-
-Then you can start the wptrunner from the Demo's clone dir:
-```
-cd wptrunner && go run .
-```
-
-Or one specific test:
-
-```
-cd wptrunner && go run . Node-childNodes.html
-```
-
-`wptrunner` command accepts `--summary` and `--json` options modifying output.
-Also `--concurrency` define the concurrency limit.
-
-:warning: Running the whole test suite will take a long time. In this case,
-it's useful to build in `releaseFast` mode to make tests faster.
-
-```
-zig build -Doptimize=ReleaseFast run
-```
-
-## Contributing
-
-Lightpanda accepts pull requests through GitHub.
-
-You have to sign our [CLA](CLA.md) during the pull request process otherwise
-we're not able to accept your contributions.
-
-## Why?
-
-### Javascript execution is mandatory for the modern web
-
-In the good old days, scraping a webpage was as easy as making an HTTP request, cURL-like. It’s not possible anymore, because Javascript is everywhere, like it or not:
-
-- Ajax, Single Page App, infinite loading, “click to display”, instant search, etc.
-- JS web frameworks: React, Vue, Angular & others
-
-### Chrome is not the right tool
-
-If we need Javascript, why not use a real web browser? Take a huge desktop application, hack it, and run it on the server. Hundreds or thousands of instances of Chrome if you use it at scale. Are you sure it’s such a good idea?
-
-- Heavy on RAM and CPU, expensive to run
-- Hard to package, deploy and maintain at scale
-- Bloated, lots of features are not useful in headless usage
+## Upstream
-### Lightpanda is built for performance
+Tracks [lightpanda-io/browser](https://github.com/lightpanda-io/browser) `main`. Periodically rebased to stay current.
-If we want both Javascript and performance in a true headless browser, we need to start from scratch. Not another iteration of Chromium, really from a blank page. Crazy right? But that’s what we did:
+## License
-- Not based on Chromium, Blink or WebKit
-- Low-level system programming language (Zig) with optimisations in mind
-- Opinionated: without graphical rendering
+AGPL-3.0 — same as Lightpanda. See [LICENSE](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
deleted file mode 100644
index a2b1ae4f66..0000000000
--- a/SECURITY.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Reporting security issues
-
-## Supported Versions
-
-Security fixes are applied to the latest `main` branch.
-
-## Reporting a Vulnerability
-
-Please **DO NOT** file a public issue, instead send your report privately to security@lightpanda.io.
-
-Security reports are greatly appreciated and we will publicly thank you for it, although we keep your name confidential if you request it.
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 0000000000..9246e383e1
Binary files /dev/null and b/assets/logo.png differ
diff --git a/build.zig b/build.zig
index d5b067945d..f37fbf99a9 100644
--- a/build.zig
+++ b/build.zig
@@ -86,6 +86,13 @@ pub fn build(b: *Build) !void {
try linkCurl(b, mod, enable_tsan);
try linkHtml5Ever(b, mod);
+ // z2d 2D graphics library for Canvas rendering
+ const z2d_dep = b.dependency("z2d", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ mod.addImport("z2d", z2d_dep.module("z2d"));
+
break :blk mod;
};
diff --git a/build.zig.zon b/build.zig.zon
index f6c231bbb2..ae6bb59dbf 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -30,6 +30,10 @@
.url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
.hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
},
+ .z2d = .{
+ .url = "git+https://github.com/vancluever/z2d?ref=v0.10.0#6d1d7bda6b696c0941d204e6042f1e8ee900e001",
+ .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
+ },
},
.paths = .{""},
}
diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json
new file mode 100644
index 0000000000..7137d497a9
--- /dev/null
+++ b/node_modules/.package-lock.json
@@ -0,0 +1,27 @@
+{
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/node_modules/ws/LICENSE b/node_modules/ws/LICENSE
new file mode 100644
index 0000000000..1da5b96a11
--- /dev/null
+++ b/node_modules/ws/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2011 Einar Otto Stangvik
+Copyright (c) 2013 Arnout Kazemier and contributors
+Copyright (c) 2016 Luigi Pinca and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/node_modules/ws/README.md b/node_modules/ws/README.md
new file mode 100644
index 0000000000..21f10df108
--- /dev/null
+++ b/node_modules/ws/README.md
@@ -0,0 +1,548 @@
+# ws: a Node.js WebSocket library
+
+[](https://www.npmjs.com/package/ws)
+[](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
+[](https://coveralls.io/github/websockets/ws)
+
+ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
+server implementation.
+
+Passes the quite extensive Autobahn test suite: [server][server-report],
+[client][client-report].
+
+**Note**: This module does not work in the browser. The client in the docs is a
+reference to a backend with the role of a client in the WebSocket communication.
+Browser clients must use the native
+[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
+object. To make the same code work seamlessly on Node.js and the browser, you
+can use one of the many wrappers available on npm, like
+[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
+
+## Table of Contents
+
+- [Protocol support](#protocol-support)
+- [Installing](#installing)
+ - [Opt-in for performance](#opt-in-for-performance)
+ - [Legacy opt-in for performance](#legacy-opt-in-for-performance)
+- [API docs](#api-docs)
+- [WebSocket compression](#websocket-compression)
+- [Usage examples](#usage-examples)
+ - [Sending and receiving text data](#sending-and-receiving-text-data)
+ - [Sending binary data](#sending-binary-data)
+ - [Simple server](#simple-server)
+ - [External HTTP/S server](#external-https-server)
+ - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
+ - [Client authentication](#client-authentication)
+ - [Server broadcast](#server-broadcast)
+ - [Round-trip time](#round-trip-time)
+ - [Use the Node.js streams API](#use-the-nodejs-streams-api)
+ - [Other examples](#other-examples)
+- [FAQ](#faq)
+ - [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
+ - [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
+ - [How to connect via a proxy?](#how-to-connect-via-a-proxy)
+- [Changelog](#changelog)
+- [License](#license)
+
+## Protocol support
+
+- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
+- **HyBi drafts 13-17** (Current default, alternatively option
+ `protocolVersion: 13`)
+
+## Installing
+
+```
+npm install ws
+```
+
+### Opt-in for performance
+
+[bufferutil][] is an optional module that can be installed alongside the ws
+module:
+
+```
+npm install --save-optional bufferutil
+```
+
+This is a binary addon that improves the performance of certain operations such
+as masking and unmasking the data payload of the WebSocket frames. Prebuilt
+binaries are available for the most popular platforms, so you don't necessarily
+need to have a C++ compiler installed on your machine.
+
+To force ws to not use bufferutil, use the
+[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
+can be useful to enhance security in systems where a user can put a package in
+the package search path of an application of another user, due to how the
+Node.js resolver algorithm works.
+
+#### Legacy opt-in for performance
+
+If you are running on an old version of Node.js (prior to v18.14.0), ws also
+supports the [utf-8-validate][] module:
+
+```
+npm install --save-optional utf-8-validate
+```
+
+This contains a binary polyfill for [`buffer.isUtf8()`][].
+
+To force ws not to use utf-8-validate, use the
+[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
+
+## API docs
+
+See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
+utility functions.
+
+## WebSocket compression
+
+ws supports the [permessage-deflate extension][permessage-deflate] which enables
+the client and server to negotiate a compression algorithm and its parameters,
+and then selectively apply it to the data payloads of each WebSocket message.
+
+The extension is disabled by default on the server and enabled by default on the
+client. It adds a significant overhead in terms of performance and memory
+consumption so we suggest to enable it only if it is really needed.
+
+Note that Node.js has a variety of issues with high-performance compression,
+where increased concurrency, especially on Linux, can lead to [catastrophic
+memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
+permessage-deflate in production, it is worthwhile to set up a test
+representative of your workload and ensure Node.js/zlib will handle it with
+acceptable performance and memory usage.
+
+Tuning of permessage-deflate can be done via the options defined below. You can
+also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
+into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
+
+See [the docs][ws-server-options] for more options.
+
+```js
+import WebSocket, { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({
+ port: 8080,
+ perMessageDeflate: {
+ zlibDeflateOptions: {
+ // See zlib defaults.
+ chunkSize: 1024,
+ memLevel: 7,
+ level: 3
+ },
+ zlibInflateOptions: {
+ chunkSize: 10 * 1024
+ },
+ // Other options settable:
+ clientNoContextTakeover: true, // Defaults to negotiated value.
+ serverNoContextTakeover: true, // Defaults to negotiated value.
+ serverMaxWindowBits: 10, // Defaults to negotiated value.
+ // Below options specified as default values.
+ concurrencyLimit: 10, // Limits zlib concurrency for perf.
+ threshold: 1024 // Size (in bytes) below which messages
+ // should not be compressed if context takeover is disabled.
+ }
+});
+```
+
+The client will only use the extension if it is supported and enabled on the
+server. To always disable the extension on the client, set the
+`perMessageDeflate` option to `false`.
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('ws://www.host.com/path', {
+ perMessageDeflate: false
+});
+```
+
+## Usage examples
+
+### Sending and receiving text data
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('ws://www.host.com/path');
+
+ws.on('error', console.error);
+
+ws.on('open', function open() {
+ ws.send('something');
+});
+
+ws.on('message', function message(data) {
+ console.log('received: %s', data);
+});
+```
+
+### Sending binary data
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('ws://www.host.com/path');
+
+ws.on('error', console.error);
+
+ws.on('open', function open() {
+ const array = new Float32Array(5);
+
+ for (var i = 0; i < array.length; ++i) {
+ array[i] = i / 2;
+ }
+
+ ws.send(array);
+});
+```
+
+### Simple server
+
+```js
+import { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+ ws.on('error', console.error);
+
+ ws.on('message', function message(data) {
+ console.log('received: %s', data);
+ });
+
+ ws.send('something');
+});
+```
+
+### External HTTP/S server
+
+```js
+import { createServer } from 'https';
+import { readFileSync } from 'fs';
+import { WebSocketServer } from 'ws';
+
+const server = createServer({
+ cert: readFileSync('/path/to/cert.pem'),
+ key: readFileSync('/path/to/key.pem')
+});
+const wss = new WebSocketServer({ server });
+
+wss.on('connection', function connection(ws) {
+ ws.on('error', console.error);
+
+ ws.on('message', function message(data) {
+ console.log('received: %s', data);
+ });
+
+ ws.send('something');
+});
+
+server.listen(8080);
+```
+
+### Multiple servers sharing a single HTTP/S server
+
+```js
+import { createServer } from 'http';
+import { WebSocketServer } from 'ws';
+
+const server = createServer();
+const wss1 = new WebSocketServer({ noServer: true });
+const wss2 = new WebSocketServer({ noServer: true });
+
+wss1.on('connection', function connection(ws) {
+ ws.on('error', console.error);
+
+ // ...
+});
+
+wss2.on('connection', function connection(ws) {
+ ws.on('error', console.error);
+
+ // ...
+});
+
+server.on('upgrade', function upgrade(request, socket, head) {
+ const { pathname } = new URL(request.url, 'wss://base.url');
+
+ if (pathname === '/foo') {
+ wss1.handleUpgrade(request, socket, head, function done(ws) {
+ wss1.emit('connection', ws, request);
+ });
+ } else if (pathname === '/bar') {
+ wss2.handleUpgrade(request, socket, head, function done(ws) {
+ wss2.emit('connection', ws, request);
+ });
+ } else {
+ socket.destroy();
+ }
+});
+
+server.listen(8080);
+```
+
+### Client authentication
+
+```js
+import { createServer } from 'http';
+import { WebSocketServer } from 'ws';
+
+function onSocketError(err) {
+ console.error(err);
+}
+
+const server = createServer();
+const wss = new WebSocketServer({ noServer: true });
+
+wss.on('connection', function connection(ws, request, client) {
+ ws.on('error', console.error);
+
+ ws.on('message', function message(data) {
+ console.log(`Received message ${data} from user ${client}`);
+ });
+});
+
+server.on('upgrade', function upgrade(request, socket, head) {
+ socket.on('error', onSocketError);
+
+ // This function is not defined on purpose. Implement it with your own logic.
+ authenticate(request, function next(err, client) {
+ if (err || !client) {
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ socket.removeListener('error', onSocketError);
+
+ wss.handleUpgrade(request, socket, head, function done(ws) {
+ wss.emit('connection', ws, request, client);
+ });
+ });
+});
+
+server.listen(8080);
+```
+
+Also see the provided [example][session-parse-example] using `express-session`.
+
+### Server broadcast
+
+A client WebSocket broadcasting to all connected WebSocket clients, including
+itself.
+
+```js
+import WebSocket, { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+ ws.on('error', console.error);
+
+ ws.on('message', function message(data, isBinary) {
+ wss.clients.forEach(function each(client) {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(data, { binary: isBinary });
+ }
+ });
+ });
+});
+```
+
+A client WebSocket broadcasting to every other connected WebSocket clients,
+excluding itself.
+
+```js
+import WebSocket, { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+ ws.on('error', console.error);
+
+ ws.on('message', function message(data, isBinary) {
+ wss.clients.forEach(function each(client) {
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
+ client.send(data, { binary: isBinary });
+ }
+ });
+ });
+});
+```
+
+### Round-trip time
+
+```js
+import WebSocket from 'ws';
+
+const ws = new WebSocket('wss://websocket-echo.com/');
+
+ws.on('error', console.error);
+
+ws.on('open', function open() {
+ console.log('connected');
+ ws.send(Date.now());
+});
+
+ws.on('close', function close() {
+ console.log('disconnected');
+});
+
+ws.on('message', function message(data) {
+ console.log(`Round-trip time: ${Date.now() - data} ms`);
+
+ setTimeout(function timeout() {
+ ws.send(Date.now());
+ }, 500);
+});
+```
+
+### Use the Node.js streams API
+
+```js
+import WebSocket, { createWebSocketStream } from 'ws';
+
+const ws = new WebSocket('wss://websocket-echo.com/');
+
+const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
+
+duplex.on('error', console.error);
+
+duplex.pipe(process.stdout);
+process.stdin.pipe(duplex);
+```
+
+### Other examples
+
+For a full example with a browser client communicating with a ws server, see the
+examples folder.
+
+Otherwise, see the test cases.
+
+## FAQ
+
+### How to get the IP address of the client?
+
+The remote IP address can be obtained from the raw socket.
+
+```js
+import { WebSocketServer } from 'ws';
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws, req) {
+ const ip = req.socket.remoteAddress;
+
+ ws.on('error', console.error);
+});
+```
+
+When the server runs behind a proxy like NGINX, the de-facto standard is to use
+the `X-Forwarded-For` header.
+
+```js
+wss.on('connection', function connection(ws, req) {
+ const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
+
+ ws.on('error', console.error);
+});
+```
+
+### How to detect and close broken connections?
+
+Sometimes, the link between the server and the client can be interrupted in a
+way that keeps both the server and the client unaware of the broken state of the
+connection (e.g. when pulling the cord).
+
+In these cases, ping messages can be used as a means to verify that the remote
+endpoint is still responsive.
+
+```js
+import { WebSocketServer } from 'ws';
+
+function heartbeat() {
+ this.isAlive = true;
+}
+
+const wss = new WebSocketServer({ port: 8080 });
+
+wss.on('connection', function connection(ws) {
+ ws.isAlive = true;
+ ws.on('error', console.error);
+ ws.on('pong', heartbeat);
+});
+
+const interval = setInterval(function ping() {
+ wss.clients.forEach(function each(ws) {
+ if (ws.isAlive === false) return ws.terminate();
+
+ ws.isAlive = false;
+ ws.ping();
+ });
+}, 30000);
+
+wss.on('close', function close() {
+ clearInterval(interval);
+});
+```
+
+Pong messages are automatically sent in response to ping messages as required by
+the spec.
+
+Just like the server example above, your clients might as well lose connection
+without knowing it. You might want to add a ping listener on your clients to
+prevent that. A simple implementation would be:
+
+```js
+import WebSocket from 'ws';
+
+function heartbeat() {
+ clearTimeout(this.pingTimeout);
+
+ // Use `WebSocket#terminate()`, which immediately destroys the connection,
+ // instead of `WebSocket#close()`, which waits for the close timer.
+ // Delay should be equal to the interval at which your server
+ // sends out pings plus a conservative assumption of the latency.
+ this.pingTimeout = setTimeout(() => {
+ this.terminate();
+ }, 30000 + 1000);
+}
+
+const client = new WebSocket('wss://websocket-echo.com/');
+
+client.on('error', console.error);
+client.on('open', heartbeat);
+client.on('ping', heartbeat);
+client.on('close', function clear() {
+ clearTimeout(this.pingTimeout);
+});
+```
+
+### How to connect via a proxy?
+
+Use a custom `http.Agent` implementation like [https-proxy-agent][] or
+[socks-proxy-agent][].
+
+## Changelog
+
+We're using the GitHub [releases][changelog] for changelog entries.
+
+## License
+
+[MIT](LICENSE)
+
+[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
+[bufferutil]: https://github.com/websockets/bufferutil
+[changelog]: https://github.com/websockets/ws/releases
+[client-report]: http://websockets.github.io/ws/autobahn/clients/
+[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
+[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
+[node-zlib-deflaterawdocs]:
+ https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
+[permessage-deflate]: https://tools.ietf.org/html/rfc7692
+[server-report]: http://websockets.github.io/ws/autobahn/servers/
+[session-parse-example]: ./examples/express-session-parse
+[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
+[utf-8-validate]: https://github.com/websockets/utf-8-validate
+[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback
diff --git a/node_modules/ws/browser.js b/node_modules/ws/browser.js
new file mode 100644
index 0000000000..ca4f628ac1
--- /dev/null
+++ b/node_modules/ws/browser.js
@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = function () {
+ throw new Error(
+ 'ws does not work in the browser. Browser clients must use the native ' +
+ 'WebSocket object'
+ );
+};
diff --git a/node_modules/ws/index.js b/node_modules/ws/index.js
new file mode 100644
index 0000000000..3fdb7b2103
--- /dev/null
+++ b/node_modules/ws/index.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const createWebSocketStream = require('./lib/stream');
+const extension = require('./lib/extension');
+const PerMessageDeflate = require('./lib/permessage-deflate');
+const Receiver = require('./lib/receiver');
+const Sender = require('./lib/sender');
+const subprotocol = require('./lib/subprotocol');
+const WebSocket = require('./lib/websocket');
+const WebSocketServer = require('./lib/websocket-server');
+
+WebSocket.createWebSocketStream = createWebSocketStream;
+WebSocket.extension = extension;
+WebSocket.PerMessageDeflate = PerMessageDeflate;
+WebSocket.Receiver = Receiver;
+WebSocket.Sender = Sender;
+WebSocket.Server = WebSocketServer;
+WebSocket.subprotocol = subprotocol;
+WebSocket.WebSocket = WebSocket;
+WebSocket.WebSocketServer = WebSocketServer;
+
+module.exports = WebSocket;
diff --git a/node_modules/ws/lib/buffer-util.js b/node_modules/ws/lib/buffer-util.js
new file mode 100644
index 0000000000..f7536e28ef
--- /dev/null
+++ b/node_modules/ws/lib/buffer-util.js
@@ -0,0 +1,131 @@
+'use strict';
+
+const { EMPTY_BUFFER } = require('./constants');
+
+const FastBuffer = Buffer[Symbol.species];
+
+/**
+ * Merges an array of buffers into a new buffer.
+ *
+ * @param {Buffer[]} list The array of buffers to concat
+ * @param {Number} totalLength The total length of buffers in the list
+ * @return {Buffer} The resulting buffer
+ * @public
+ */
+function concat(list, totalLength) {
+ if (list.length === 0) return EMPTY_BUFFER;
+ if (list.length === 1) return list[0];
+
+ const target = Buffer.allocUnsafe(totalLength);
+ let offset = 0;
+
+ for (let i = 0; i < list.length; i++) {
+ const buf = list[i];
+ target.set(buf, offset);
+ offset += buf.length;
+ }
+
+ if (offset < totalLength) {
+ return new FastBuffer(target.buffer, target.byteOffset, offset);
+ }
+
+ return target;
+}
+
+/**
+ * Masks a buffer using the given mask.
+ *
+ * @param {Buffer} source The buffer to mask
+ * @param {Buffer} mask The mask to use
+ * @param {Buffer} output The buffer where to store the result
+ * @param {Number} offset The offset at which to start writing
+ * @param {Number} length The number of bytes to mask.
+ * @public
+ */
+function _mask(source, mask, output, offset, length) {
+ for (let i = 0; i < length; i++) {
+ output[offset + i] = source[i] ^ mask[i & 3];
+ }
+}
+
+/**
+ * Unmasks a buffer using the given mask.
+ *
+ * @param {Buffer} buffer The buffer to unmask
+ * @param {Buffer} mask The mask to use
+ * @public
+ */
+function _unmask(buffer, mask) {
+ for (let i = 0; i < buffer.length; i++) {
+ buffer[i] ^= mask[i & 3];
+ }
+}
+
+/**
+ * Converts a buffer to an `ArrayBuffer`.
+ *
+ * @param {Buffer} buf The buffer to convert
+ * @return {ArrayBuffer} Converted buffer
+ * @public
+ */
+function toArrayBuffer(buf) {
+ if (buf.length === buf.buffer.byteLength) {
+ return buf.buffer;
+ }
+
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
+}
+
+/**
+ * Converts `data` to a `Buffer`.
+ *
+ * @param {*} data The data to convert
+ * @return {Buffer} The buffer
+ * @throws {TypeError}
+ * @public
+ */
+function toBuffer(data) {
+ toBuffer.readOnly = true;
+
+ if (Buffer.isBuffer(data)) return data;
+
+ let buf;
+
+ if (data instanceof ArrayBuffer) {
+ buf = new FastBuffer(data);
+ } else if (ArrayBuffer.isView(data)) {
+ buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
+ } else {
+ buf = Buffer.from(data);
+ toBuffer.readOnly = false;
+ }
+
+ return buf;
+}
+
+module.exports = {
+ concat,
+ mask: _mask,
+ toArrayBuffer,
+ toBuffer,
+ unmask: _unmask
+};
+
+/* istanbul ignore else */
+if (!process.env.WS_NO_BUFFER_UTIL) {
+ try {
+ const bufferUtil = require('bufferutil');
+
+ module.exports.mask = function (source, mask, output, offset, length) {
+ if (length < 48) _mask(source, mask, output, offset, length);
+ else bufferUtil.mask(source, mask, output, offset, length);
+ };
+
+ module.exports.unmask = function (buffer, mask) {
+ if (buffer.length < 32) _unmask(buffer, mask);
+ else bufferUtil.unmask(buffer, mask);
+ };
+ } catch (e) {
+ // Continue regardless of the error.
+ }
+}
diff --git a/node_modules/ws/lib/constants.js b/node_modules/ws/lib/constants.js
new file mode 100644
index 0000000000..69b2fe3c43
--- /dev/null
+++ b/node_modules/ws/lib/constants.js
@@ -0,0 +1,19 @@
+'use strict';
+
+const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
+const hasBlob = typeof Blob !== 'undefined';
+
+if (hasBlob) BINARY_TYPES.push('blob');
+
+module.exports = {
+ BINARY_TYPES,
+ CLOSE_TIMEOUT: 30000,
+ EMPTY_BUFFER: Buffer.alloc(0),
+ GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
+ hasBlob,
+ kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
+ kListener: Symbol('kListener'),
+ kStatusCode: Symbol('status-code'),
+ kWebSocket: Symbol('websocket'),
+ NOOP: () => {}
+};
diff --git a/node_modules/ws/lib/event-target.js b/node_modules/ws/lib/event-target.js
new file mode 100644
index 0000000000..fea4cbc52c
--- /dev/null
+++ b/node_modules/ws/lib/event-target.js
@@ -0,0 +1,292 @@
+'use strict';
+
+const { kForOnEventAttribute, kListener } = require('./constants');
+
+const kCode = Symbol('kCode');
+const kData = Symbol('kData');
+const kError = Symbol('kError');
+const kMessage = Symbol('kMessage');
+const kReason = Symbol('kReason');
+const kTarget = Symbol('kTarget');
+const kType = Symbol('kType');
+const kWasClean = Symbol('kWasClean');
+
+/**
+ * Class representing an event.
+ */
+class Event {
+ /**
+ * Create a new `Event`.
+ *
+ * @param {String} type The name of the event
+ * @throws {TypeError} If the `type` argument is not specified
+ */
+ constructor(type) {
+ this[kTarget] = null;
+ this[kType] = type;
+ }
+
+ /**
+ * @type {*}
+ */
+ get target() {
+ return this[kTarget];
+ }
+
+ /**
+ * @type {String}
+ */
+ get type() {
+ return this[kType];
+ }
+}
+
+Object.defineProperty(Event.prototype, 'target', { enumerable: true });
+Object.defineProperty(Event.prototype, 'type', { enumerable: true });
+
+/**
+ * Class representing a close event.
+ *
+ * @extends Event
+ */
+class CloseEvent extends Event {
+ /**
+ * Create a new `CloseEvent`.
+ *
+ * @param {String} type The name of the event
+ * @param {Object} [options] A dictionary object that allows for setting
+ * attributes via object members of the same name
+ * @param {Number} [options.code=0] The status code explaining why the
+ * connection was closed
+ * @param {String} [options.reason=''] A human-readable string explaining why
+ * the connection was closed
+ * @param {Boolean} [options.wasClean=false] Indicates whether or not the
+ * connection was cleanly closed
+ */
+ constructor(type, options = {}) {
+ super(type);
+
+ this[kCode] = options.code === undefined ? 0 : options.code;
+ this[kReason] = options.reason === undefined ? '' : options.reason;
+ this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
+ }
+
+ /**
+ * @type {Number}
+ */
+ get code() {
+ return this[kCode];
+ }
+
+ /**
+ * @type {String}
+ */
+ get reason() {
+ return this[kReason];
+ }
+
+ /**
+ * @type {Boolean}
+ */
+ get wasClean() {
+ return this[kWasClean];
+ }
+}
+
+Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
+Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
+Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
+
+/**
+ * Class representing an error event.
+ *
+ * @extends Event
+ */
+class ErrorEvent extends Event {
+ /**
+ * Create a new `ErrorEvent`.
+ *
+ * @param {String} type The name of the event
+ * @param {Object} [options] A dictionary object that allows for setting
+ * attributes via object members of the same name
+ * @param {*} [options.error=null] The error that generated this event
+ * @param {String} [options.message=''] The error message
+ */
+ constructor(type, options = {}) {
+ super(type);
+
+ this[kError] = options.error === undefined ? null : options.error;
+ this[kMessage] = options.message === undefined ? '' : options.message;
+ }
+
+ /**
+ * @type {*}
+ */
+ get error() {
+ return this[kError];
+ }
+
+ /**
+ * @type {String}
+ */
+ get message() {
+ return this[kMessage];
+ }
+}
+
+Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
+Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
+
+/**
+ * Class representing a message event.
+ *
+ * @extends Event
+ */
+class MessageEvent extends Event {
+ /**
+ * Create a new `MessageEvent`.
+ *
+ * @param {String} type The name of the event
+ * @param {Object} [options] A dictionary object that allows for setting
+ * attributes via object members of the same name
+ * @param {*} [options.data=null] The message content
+ */
+ constructor(type, options = {}) {
+ super(type);
+
+ this[kData] = options.data === undefined ? null : options.data;
+ }
+
+ /**
+ * @type {*}
+ */
+ get data() {
+ return this[kData];
+ }
+}
+
+Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
+
+/**
+ * This provides methods for emulating the `EventTarget` interface. It's not
+ * meant to be used directly.
+ *
+ * @mixin
+ */
+const EventTarget = {
+ /**
+ * Register an event listener.
+ *
+ * @param {String} type A string representing the event type to listen for
+ * @param {(Function|Object)} handler The listener to add
+ * @param {Object} [options] An options object specifies characteristics about
+ * the event listener
+ * @param {Boolean} [options.once=false] A `Boolean` indicating that the
+ * listener should be invoked at most once after being added. If `true`,
+ * the listener would be automatically removed when invoked.
+ * @public
+ */
+ addEventListener(type, handler, options = {}) {
+ for (const listener of this.listeners(type)) {
+ if (
+ !options[kForOnEventAttribute] &&
+ listener[kListener] === handler &&
+ !listener[kForOnEventAttribute]
+ ) {
+ return;
+ }
+ }
+
+ let wrapper;
+
+ if (type === 'message') {
+ wrapper = function onMessage(data, isBinary) {
+ const event = new MessageEvent('message', {
+ data: isBinary ? data : data.toString()
+ });
+
+ event[kTarget] = this;
+ callListener(handler, this, event);
+ };
+ } else if (type === 'close') {
+ wrapper = function onClose(code, message) {
+ const event = new CloseEvent('close', {
+ code,
+ reason: message.toString(),
+ wasClean: this._closeFrameReceived && this._closeFrameSent
+ });
+
+ event[kTarget] = this;
+ callListener(handler, this, event);
+ };
+ } else if (type === 'error') {
+ wrapper = function onError(error) {
+ const event = new ErrorEvent('error', {
+ error,
+ message: error.message
+ });
+
+ event[kTarget] = this;
+ callListener(handler, this, event);
+ };
+ } else if (type === 'open') {
+ wrapper = function onOpen() {
+ const event = new Event('open');
+
+ event[kTarget] = this;
+ callListener(handler, this, event);
+ };
+ } else {
+ return;
+ }
+
+ wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
+ wrapper[kListener] = handler;
+
+ if (options.once) {
+ this.once(type, wrapper);
+ } else {
+ this.on(type, wrapper);
+ }
+ },
+
+ /**
+ * Remove an event listener.
+ *
+ * @param {String} type A string representing the event type to remove
+ * @param {(Function|Object)} handler The listener to remove
+ * @public
+ */
+ removeEventListener(type, handler) {
+ for (const listener of this.listeners(type)) {
+ if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
+ this.removeListener(type, listener);
+ break;
+ }
+ }
+ }
+};
+
+module.exports = {
+ CloseEvent,
+ ErrorEvent,
+ Event,
+ EventTarget,
+ MessageEvent
+};
+
+/**
+ * Call an event listener
+ *
+ * @param {(Function|Object)} listener The listener to call
+ * @param {*} thisArg The value to use as `this`` when calling the listener
+ * @param {Event} event The event to pass to the listener
+ * @private
+ */
+function callListener(listener, thisArg, event) {
+ if (typeof listener === 'object' && listener.handleEvent) {
+ listener.handleEvent.call(listener, event);
+ } else {
+ listener.call(thisArg, event);
+ }
+}
diff --git a/node_modules/ws/lib/extension.js b/node_modules/ws/lib/extension.js
new file mode 100644
index 0000000000..3d7895c1b0
--- /dev/null
+++ b/node_modules/ws/lib/extension.js
@@ -0,0 +1,203 @@
+'use strict';
+
+const { tokenChars } = require('./validation');
+
+/**
+ * Adds an offer to the map of extension offers or a parameter to the map of
+ * parameters.
+ *
+ * @param {Object} dest The map of extension offers or parameters
+ * @param {String} name The extension or parameter name
+ * @param {(Object|Boolean|String)} elem The extension parameters or the
+ * parameter value
+ * @private
+ */
+function push(dest, name, elem) {
+ if (dest[name] === undefined) dest[name] = [elem];
+ else dest[name].push(elem);
+}
+
+/**
+ * Parses the `Sec-WebSocket-Extensions` header into an object.
+ *
+ * @param {String} header The field value of the header
+ * @return {Object} The parsed object
+ * @public
+ */
+function parse(header) {
+ const offers = Object.create(null);
+ let params = Object.create(null);
+ let mustUnescape = false;
+ let isEscaping = false;
+ let inQuotes = false;
+ let extensionName;
+ let paramName;
+ let start = -1;
+ let code = -1;
+ let end = -1;
+ let i = 0;
+
+ for (; i < header.length; i++) {
+ code = header.charCodeAt(i);
+
+ if (extensionName === undefined) {
+ if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (
+ i !== 0 &&
+ (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
+ ) {
+ if (end === -1 && start !== -1) end = i;
+ } else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+ const name = header.slice(start, end);
+ if (code === 0x2c) {
+ push(offers, name, params);
+ params = Object.create(null);
+ } else {
+ extensionName = name;
+ }
+
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ } else if (paramName === undefined) {
+ if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (code === 0x20 || code === 0x09) {
+ if (end === -1 && start !== -1) end = i;
+ } else if (code === 0x3b || code === 0x2c) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+ push(params, header.slice(start, end), true);
+ if (code === 0x2c) {
+ push(offers, extensionName, params);
+ params = Object.create(null);
+ extensionName = undefined;
+ }
+
+ start = end = -1;
+ } else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
+ paramName = header.slice(start, i);
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ } else {
+ //
+ // The value of a quoted-string after unescaping must conform to the
+ // token ABNF, so only token characters are valid.
+ // Ref: https://tools.ietf.org/html/rfc6455#section-9.1
+ //
+ if (isEscaping) {
+ if (tokenChars[code] !== 1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ if (start === -1) start = i;
+ else if (!mustUnescape) mustUnescape = true;
+ isEscaping = false;
+ } else if (inQuotes) {
+ if (tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (code === 0x22 /* '"' */ && start !== -1) {
+ inQuotes = false;
+ end = i;
+ } else if (code === 0x5c /* '\' */) {
+ isEscaping = true;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ } else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
+ inQuotes = true;
+ } else if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (start !== -1 && (code === 0x20 || code === 0x09)) {
+ if (end === -1) end = i;
+ } else if (code === 0x3b || code === 0x2c) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+ let value = header.slice(start, end);
+ if (mustUnescape) {
+ value = value.replace(/\\/g, '');
+ mustUnescape = false;
+ }
+ push(params, paramName, value);
+ if (code === 0x2c) {
+ push(offers, extensionName, params);
+ params = Object.create(null);
+ extensionName = undefined;
+ }
+
+ paramName = undefined;
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ }
+ }
+
+ if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
+ throw new SyntaxError('Unexpected end of input');
+ }
+
+ if (end === -1) end = i;
+ const token = header.slice(start, end);
+ if (extensionName === undefined) {
+ push(offers, token, params);
+ } else {
+ if (paramName === undefined) {
+ push(params, token, true);
+ } else if (mustUnescape) {
+ push(params, paramName, token.replace(/\\/g, ''));
+ } else {
+ push(params, paramName, token);
+ }
+ push(offers, extensionName, params);
+ }
+
+ return offers;
+}
+
+/**
+ * Builds the `Sec-WebSocket-Extensions` header field value.
+ *
+ * @param {Object} extensions The map of extensions and parameters to format
+ * @return {String} A string representing the given object
+ * @public
+ */
+function format(extensions) {
+ return Object.keys(extensions)
+ .map((extension) => {
+ let configurations = extensions[extension];
+ if (!Array.isArray(configurations)) configurations = [configurations];
+ return configurations
+ .map((params) => {
+ return [extension]
+ .concat(
+ Object.keys(params).map((k) => {
+ let values = params[k];
+ if (!Array.isArray(values)) values = [values];
+ return values
+ .map((v) => (v === true ? k : `${k}=${v}`))
+ .join('; ');
+ })
+ )
+ .join('; ');
+ })
+ .join(', ');
+ })
+ .join(', ');
+}
+
+module.exports = { format, parse };
diff --git a/node_modules/ws/lib/limiter.js b/node_modules/ws/lib/limiter.js
new file mode 100644
index 0000000000..3fd35784ea
--- /dev/null
+++ b/node_modules/ws/lib/limiter.js
@@ -0,0 +1,55 @@
+'use strict';
+
+const kDone = Symbol('kDone');
+const kRun = Symbol('kRun');
+
+/**
+ * A very simple job queue with adjustable concurrency. Adapted from
+ * https://github.com/STRML/async-limiter
+ */
+class Limiter {
+ /**
+ * Creates a new `Limiter`.
+ *
+ * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
+ * to run concurrently
+ */
+ constructor(concurrency) {
+ this[kDone] = () => {
+ this.pending--;
+ this[kRun]();
+ };
+ this.concurrency = concurrency || Infinity;
+ this.jobs = [];
+ this.pending = 0;
+ }
+
+ /**
+ * Adds a job to the queue.
+ *
+ * @param {Function} job The job to run
+ * @public
+ */
+ add(job) {
+ this.jobs.push(job);
+ this[kRun]();
+ }
+
+ /**
+ * Removes a job from the queue and runs it if possible.
+ *
+ * @private
+ */
+ [kRun]() {
+ if (this.pending === this.concurrency) return;
+
+ if (this.jobs.length) {
+ const job = this.jobs.shift();
+
+ this.pending++;
+ job(this[kDone]);
+ }
+ }
+}
+
+module.exports = Limiter;
diff --git a/node_modules/ws/lib/permessage-deflate.js b/node_modules/ws/lib/permessage-deflate.js
new file mode 100644
index 0000000000..aa5db761df
--- /dev/null
+++ b/node_modules/ws/lib/permessage-deflate.js
@@ -0,0 +1,528 @@
+'use strict';
+
+const zlib = require('zlib');
+
+const bufferUtil = require('./buffer-util');
+const Limiter = require('./limiter');
+const { kStatusCode } = require('./constants');
+
+const FastBuffer = Buffer[Symbol.species];
+const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
+const kPerMessageDeflate = Symbol('permessage-deflate');
+const kTotalLength = Symbol('total-length');
+const kCallback = Symbol('callback');
+const kBuffers = Symbol('buffers');
+const kError = Symbol('error');
+
+//
+// We limit zlib concurrency, which prevents severe memory fragmentation
+// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
+// and https://github.com/websockets/ws/issues/1202
+//
+// Intentionally global; it's the global thread pool that's an issue.
+//
+let zlibLimiter;
+
+/**
+ * permessage-deflate implementation.
+ */
+class PerMessageDeflate {
+ /**
+ * Creates a PerMessageDeflate instance.
+ *
+ * @param {Object} [options] Configuration options
+ * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
+ * for, or request, a custom client window size
+ * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
+ * acknowledge disabling of client context takeover
+ * @param {Number} [options.concurrencyLimit=10] The number of concurrent
+ * calls to zlib
+ * @param {Boolean} [options.isServer=false] Create the instance in either
+ * server or client mode
+ * @param {Number} [options.maxPayload=0] The maximum allowed message length
+ * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
+ * use of a custom server window size
+ * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
+ * disabling of server context takeover
+ * @param {Number} [options.threshold=1024] Size (in bytes) below which
+ * messages should not be compressed if context takeover is disabled
+ * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
+ * deflate
+ * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
+ * inflate
+ */
+ constructor(options) {
+ this._options = options || {};
+ this._threshold =
+ this._options.threshold !== undefined ? this._options.threshold : 1024;
+ this._maxPayload = this._options.maxPayload | 0;
+ this._isServer = !!this._options.isServer;
+ this._deflate = null;
+ this._inflate = null;
+
+ this.params = null;
+
+ if (!zlibLimiter) {
+ const concurrency =
+ this._options.concurrencyLimit !== undefined
+ ? this._options.concurrencyLimit
+ : 10;
+ zlibLimiter = new Limiter(concurrency);
+ }
+ }
+
+ /**
+ * @type {String}
+ */
+ static get extensionName() {
+ return 'permessage-deflate';
+ }
+
+ /**
+ * Create an extension negotiation offer.
+ *
+ * @return {Object} Extension parameters
+ * @public
+ */
+ offer() {
+ const params = {};
+
+ if (this._options.serverNoContextTakeover) {
+ params.server_no_context_takeover = true;
+ }
+ if (this._options.clientNoContextTakeover) {
+ params.client_no_context_takeover = true;
+ }
+ if (this._options.serverMaxWindowBits) {
+ params.server_max_window_bits = this._options.serverMaxWindowBits;
+ }
+ if (this._options.clientMaxWindowBits) {
+ params.client_max_window_bits = this._options.clientMaxWindowBits;
+ } else if (this._options.clientMaxWindowBits == null) {
+ params.client_max_window_bits = true;
+ }
+
+ return params;
+ }
+
+ /**
+ * Accept an extension negotiation offer/response.
+ *
+ * @param {Array} configurations The extension negotiation offers/reponse
+ * @return {Object} Accepted configuration
+ * @public
+ */
+ accept(configurations) {
+ configurations = this.normalizeParams(configurations);
+
+ this.params = this._isServer
+ ? this.acceptAsServer(configurations)
+ : this.acceptAsClient(configurations);
+
+ return this.params;
+ }
+
+ /**
+ * Releases all resources used by the extension.
+ *
+ * @public
+ */
+ cleanup() {
+ if (this._inflate) {
+ this._inflate.close();
+ this._inflate = null;
+ }
+
+ if (this._deflate) {
+ const callback = this._deflate[kCallback];
+
+ this._deflate.close();
+ this._deflate = null;
+
+ if (callback) {
+ callback(
+ new Error(
+ 'The deflate stream was closed while data was being processed'
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Accept an extension negotiation offer.
+ *
+ * @param {Array} offers The extension negotiation offers
+ * @return {Object} Accepted configuration
+ * @private
+ */
+ acceptAsServer(offers) {
+ const opts = this._options;
+ const accepted = offers.find((params) => {
+ if (
+ (opts.serverNoContextTakeover === false &&
+ params.server_no_context_takeover) ||
+ (params.server_max_window_bits &&
+ (opts.serverMaxWindowBits === false ||
+ (typeof opts.serverMaxWindowBits === 'number' &&
+ opts.serverMaxWindowBits > params.server_max_window_bits))) ||
+ (typeof opts.clientMaxWindowBits === 'number' &&
+ !params.client_max_window_bits)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ if (!accepted) {
+ throw new Error('None of the extension offers can be accepted');
+ }
+
+ if (opts.serverNoContextTakeover) {
+ accepted.server_no_context_takeover = true;
+ }
+ if (opts.clientNoContextTakeover) {
+ accepted.client_no_context_takeover = true;
+ }
+ if (typeof opts.serverMaxWindowBits === 'number') {
+ accepted.server_max_window_bits = opts.serverMaxWindowBits;
+ }
+ if (typeof opts.clientMaxWindowBits === 'number') {
+ accepted.client_max_window_bits = opts.clientMaxWindowBits;
+ } else if (
+ accepted.client_max_window_bits === true ||
+ opts.clientMaxWindowBits === false
+ ) {
+ delete accepted.client_max_window_bits;
+ }
+
+ return accepted;
+ }
+
+ /**
+ * Accept the extension negotiation response.
+ *
+ * @param {Array} response The extension negotiation response
+ * @return {Object} Accepted configuration
+ * @private
+ */
+ acceptAsClient(response) {
+ const params = response[0];
+
+ if (
+ this._options.clientNoContextTakeover === false &&
+ params.client_no_context_takeover
+ ) {
+ throw new Error('Unexpected parameter "client_no_context_takeover"');
+ }
+
+ if (!params.client_max_window_bits) {
+ if (typeof this._options.clientMaxWindowBits === 'number') {
+ params.client_max_window_bits = this._options.clientMaxWindowBits;
+ }
+ } else if (
+ this._options.clientMaxWindowBits === false ||
+ (typeof this._options.clientMaxWindowBits === 'number' &&
+ params.client_max_window_bits > this._options.clientMaxWindowBits)
+ ) {
+ throw new Error(
+ 'Unexpected or invalid parameter "client_max_window_bits"'
+ );
+ }
+
+ return params;
+ }
+
+ /**
+ * Normalize parameters.
+ *
+ * @param {Array} configurations The extension negotiation offers/reponse
+ * @return {Array} The offers/response with normalized parameters
+ * @private
+ */
+ normalizeParams(configurations) {
+ configurations.forEach((params) => {
+ Object.keys(params).forEach((key) => {
+ let value = params[key];
+
+ if (value.length > 1) {
+ throw new Error(`Parameter "${key}" must have only a single value`);
+ }
+
+ value = value[0];
+
+ if (key === 'client_max_window_bits') {
+ if (value !== true) {
+ const num = +value;
+ if (!Number.isInteger(num) || num < 8 || num > 15) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ value = num;
+ } else if (!this._isServer) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ } else if (key === 'server_max_window_bits') {
+ const num = +value;
+ if (!Number.isInteger(num) || num < 8 || num > 15) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ value = num;
+ } else if (
+ key === 'client_no_context_takeover' ||
+ key === 'server_no_context_takeover'
+ ) {
+ if (value !== true) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ } else {
+ throw new Error(`Unknown parameter "${key}"`);
+ }
+
+ params[key] = value;
+ });
+ });
+
+ return configurations;
+ }
+
+ /**
+ * Decompress data. Concurrency limited.
+ *
+ * @param {Buffer} data Compressed data
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @public
+ */
+ decompress(data, fin, callback) {
+ zlibLimiter.add((done) => {
+ this._decompress(data, fin, (err, result) => {
+ done();
+ callback(err, result);
+ });
+ });
+ }
+
+ /**
+ * Compress data. Concurrency limited.
+ *
+ * @param {(Buffer|String)} data Data to compress
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @public
+ */
+ compress(data, fin, callback) {
+ zlibLimiter.add((done) => {
+ this._compress(data, fin, (err, result) => {
+ done();
+ callback(err, result);
+ });
+ });
+ }
+
+ /**
+ * Decompress data.
+ *
+ * @param {Buffer} data Compressed data
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @private
+ */
+ _decompress(data, fin, callback) {
+ const endpoint = this._isServer ? 'client' : 'server';
+
+ if (!this._inflate) {
+ const key = `${endpoint}_max_window_bits`;
+ const windowBits =
+ typeof this.params[key] !== 'number'
+ ? zlib.Z_DEFAULT_WINDOWBITS
+ : this.params[key];
+
+ this._inflate = zlib.createInflateRaw({
+ ...this._options.zlibInflateOptions,
+ windowBits
+ });
+ this._inflate[kPerMessageDeflate] = this;
+ this._inflate[kTotalLength] = 0;
+ this._inflate[kBuffers] = [];
+ this._inflate.on('error', inflateOnError);
+ this._inflate.on('data', inflateOnData);
+ }
+
+ this._inflate[kCallback] = callback;
+
+ this._inflate.write(data);
+ if (fin) this._inflate.write(TRAILER);
+
+ this._inflate.flush(() => {
+ const err = this._inflate[kError];
+
+ if (err) {
+ this._inflate.close();
+ this._inflate = null;
+ callback(err);
+ return;
+ }
+
+ const data = bufferUtil.concat(
+ this._inflate[kBuffers],
+ this._inflate[kTotalLength]
+ );
+
+ if (this._inflate._readableState.endEmitted) {
+ this._inflate.close();
+ this._inflate = null;
+ } else {
+ this._inflate[kTotalLength] = 0;
+ this._inflate[kBuffers] = [];
+
+ if (fin && this.params[`${endpoint}_no_context_takeover`]) {
+ this._inflate.reset();
+ }
+ }
+
+ callback(null, data);
+ });
+ }
+
+ /**
+ * Compress data.
+ *
+ * @param {(Buffer|String)} data Data to compress
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @private
+ */
+ _compress(data, fin, callback) {
+ const endpoint = this._isServer ? 'server' : 'client';
+
+ if (!this._deflate) {
+ const key = `${endpoint}_max_window_bits`;
+ const windowBits =
+ typeof this.params[key] !== 'number'
+ ? zlib.Z_DEFAULT_WINDOWBITS
+ : this.params[key];
+
+ this._deflate = zlib.createDeflateRaw({
+ ...this._options.zlibDeflateOptions,
+ windowBits
+ });
+
+ this._deflate[kTotalLength] = 0;
+ this._deflate[kBuffers] = [];
+
+ this._deflate.on('data', deflateOnData);
+ }
+
+ this._deflate[kCallback] = callback;
+
+ this._deflate.write(data);
+ this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
+ if (!this._deflate) {
+ //
+ // The deflate stream was closed while data was being processed.
+ //
+ return;
+ }
+
+ let data = bufferUtil.concat(
+ this._deflate[kBuffers],
+ this._deflate[kTotalLength]
+ );
+
+ if (fin) {
+ data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
+ }
+
+ //
+ // Ensure that the callback will not be called again in
+ // `PerMessageDeflate#cleanup()`.
+ //
+ this._deflate[kCallback] = null;
+
+ this._deflate[kTotalLength] = 0;
+ this._deflate[kBuffers] = [];
+
+ if (fin && this.params[`${endpoint}_no_context_takeover`]) {
+ this._deflate.reset();
+ }
+
+ callback(null, data);
+ });
+ }
+}
+
+module.exports = PerMessageDeflate;
+
+/**
+ * The listener of the `zlib.DeflateRaw` stream `'data'` event.
+ *
+ * @param {Buffer} chunk A chunk of data
+ * @private
+ */
+function deflateOnData(chunk) {
+ this[kBuffers].push(chunk);
+ this[kTotalLength] += chunk.length;
+}
+
+/**
+ * The listener of the `zlib.InflateRaw` stream `'data'` event.
+ *
+ * @param {Buffer} chunk A chunk of data
+ * @private
+ */
+function inflateOnData(chunk) {
+ this[kTotalLength] += chunk.length;
+
+ if (
+ this[kPerMessageDeflate]._maxPayload < 1 ||
+ this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
+ ) {
+ this[kBuffers].push(chunk);
+ return;
+ }
+
+ this[kError] = new RangeError('Max payload size exceeded');
+ this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
+ this[kError][kStatusCode] = 1009;
+ this.removeListener('data', inflateOnData);
+
+ //
+ // The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
+ // fact that in Node.js versions prior to 13.10.0, the callback for
+ // `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
+ // `zlib.reset()` ensures that either the callback is invoked or an error is
+ // emitted.
+ //
+ this.reset();
+}
+
+/**
+ * The listener of the `zlib.InflateRaw` stream `'error'` event.
+ *
+ * @param {Error} err The emitted error
+ * @private
+ */
+function inflateOnError(err) {
+ //
+ // There is no need to call `Zlib#close()` as the handle is automatically
+ // closed when an error is emitted.
+ //
+ this[kPerMessageDeflate]._inflate = null;
+
+ if (this[kError]) {
+ this[kCallback](this[kError]);
+ return;
+ }
+
+ err[kStatusCode] = 1007;
+ this[kCallback](err);
+}
diff --git a/node_modules/ws/lib/receiver.js b/node_modules/ws/lib/receiver.js
new file mode 100644
index 0000000000..54d9b4fadb
--- /dev/null
+++ b/node_modules/ws/lib/receiver.js
@@ -0,0 +1,706 @@
+'use strict';
+
+const { Writable } = require('stream');
+
+const PerMessageDeflate = require('./permessage-deflate');
+const {
+ BINARY_TYPES,
+ EMPTY_BUFFER,
+ kStatusCode,
+ kWebSocket
+} = require('./constants');
+const { concat, toArrayBuffer, unmask } = require('./buffer-util');
+const { isValidStatusCode, isValidUTF8 } = require('./validation');
+
+const FastBuffer = Buffer[Symbol.species];
+
+const GET_INFO = 0;
+const GET_PAYLOAD_LENGTH_16 = 1;
+const GET_PAYLOAD_LENGTH_64 = 2;
+const GET_MASK = 3;
+const GET_DATA = 4;
+const INFLATING = 5;
+const DEFER_EVENT = 6;
+
+/**
+ * HyBi Receiver implementation.
+ *
+ * @extends Writable
+ */
+class Receiver extends Writable {
+ /**
+ * Creates a Receiver instance.
+ *
+ * @param {Object} [options] Options object
+ * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
+ * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
+ * multiple times in the same tick
+ * @param {String} [options.binaryType=nodebuffer] The type for binary data
+ * @param {Object} [options.extensions] An object containing the negotiated
+ * extensions
+ * @param {Boolean} [options.isServer=false] Specifies whether to operate in
+ * client or server mode
+ * @param {Number} [options.maxPayload=0] The maximum allowed message length
+ * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
+ * not to skip UTF-8 validation for text and close messages
+ */
+ constructor(options = {}) {
+ super();
+
+ this._allowSynchronousEvents =
+ options.allowSynchronousEvents !== undefined
+ ? options.allowSynchronousEvents
+ : true;
+ this._binaryType = options.binaryType || BINARY_TYPES[0];
+ this._extensions = options.extensions || {};
+ this._isServer = !!options.isServer;
+ this._maxPayload = options.maxPayload | 0;
+ this._skipUTF8Validation = !!options.skipUTF8Validation;
+ this[kWebSocket] = undefined;
+
+ this._bufferedBytes = 0;
+ this._buffers = [];
+
+ this._compressed = false;
+ this._payloadLength = 0;
+ this._mask = undefined;
+ this._fragmented = 0;
+ this._masked = false;
+ this._fin = false;
+ this._opcode = 0;
+
+ this._totalPayloadLength = 0;
+ this._messageLength = 0;
+ this._fragments = [];
+
+ this._errored = false;
+ this._loop = false;
+ this._state = GET_INFO;
+ }
+
+ /**
+ * Implements `Writable.prototype._write()`.
+ *
+ * @param {Buffer} chunk The chunk of data to write
+ * @param {String} encoding The character encoding of `chunk`
+ * @param {Function} cb Callback
+ * @private
+ */
+ _write(chunk, encoding, cb) {
+ if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
+
+ this._bufferedBytes += chunk.length;
+ this._buffers.push(chunk);
+ this.startLoop(cb);
+ }
+
+ /**
+ * Consumes `n` bytes from the buffered data.
+ *
+ * @param {Number} n The number of bytes to consume
+ * @return {Buffer} The consumed bytes
+ * @private
+ */
+ consume(n) {
+ this._bufferedBytes -= n;
+
+ if (n === this._buffers[0].length) return this._buffers.shift();
+
+ if (n < this._buffers[0].length) {
+ const buf = this._buffers[0];
+ this._buffers[0] = new FastBuffer(
+ buf.buffer,
+ buf.byteOffset + n,
+ buf.length - n
+ );
+
+ return new FastBuffer(buf.buffer, buf.byteOffset, n);
+ }
+
+ const dst = Buffer.allocUnsafe(n);
+
+ do {
+ const buf = this._buffers[0];
+ const offset = dst.length - n;
+
+ if (n >= buf.length) {
+ dst.set(this._buffers.shift(), offset);
+ } else {
+ dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
+ this._buffers[0] = new FastBuffer(
+ buf.buffer,
+ buf.byteOffset + n,
+ buf.length - n
+ );
+ }
+
+ n -= buf.length;
+ } while (n > 0);
+
+ return dst;
+ }
+
+ /**
+ * Starts the parsing loop.
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ startLoop(cb) {
+ this._loop = true;
+
+ do {
+ switch (this._state) {
+ case GET_INFO:
+ this.getInfo(cb);
+ break;
+ case GET_PAYLOAD_LENGTH_16:
+ this.getPayloadLength16(cb);
+ break;
+ case GET_PAYLOAD_LENGTH_64:
+ this.getPayloadLength64(cb);
+ break;
+ case GET_MASK:
+ this.getMask();
+ break;
+ case GET_DATA:
+ this.getData(cb);
+ break;
+ case INFLATING:
+ case DEFER_EVENT:
+ this._loop = false;
+ return;
+ }
+ } while (this._loop);
+
+ if (!this._errored) cb();
+ }
+
+ /**
+ * Reads the first two bytes of a frame.
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ getInfo(cb) {
+ if (this._bufferedBytes < 2) {
+ this._loop = false;
+ return;
+ }
+
+ const buf = this.consume(2);
+
+ if ((buf[0] & 0x30) !== 0x00) {
+ const error = this.createError(
+ RangeError,
+ 'RSV2 and RSV3 must be clear',
+ true,
+ 1002,
+ 'WS_ERR_UNEXPECTED_RSV_2_3'
+ );
+
+ cb(error);
+ return;
+ }
+
+ const compressed = (buf[0] & 0x40) === 0x40;
+
+ if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
+ const error = this.createError(
+ RangeError,
+ 'RSV1 must be clear',
+ true,
+ 1002,
+ 'WS_ERR_UNEXPECTED_RSV_1'
+ );
+
+ cb(error);
+ return;
+ }
+
+ this._fin = (buf[0] & 0x80) === 0x80;
+ this._opcode = buf[0] & 0x0f;
+ this._payloadLength = buf[1] & 0x7f;
+
+ if (this._opcode === 0x00) {
+ if (compressed) {
+ const error = this.createError(
+ RangeError,
+ 'RSV1 must be clear',
+ true,
+ 1002,
+ 'WS_ERR_UNEXPECTED_RSV_1'
+ );
+
+ cb(error);
+ return;
+ }
+
+ if (!this._fragmented) {
+ const error = this.createError(
+ RangeError,
+ 'invalid opcode 0',
+ true,
+ 1002,
+ 'WS_ERR_INVALID_OPCODE'
+ );
+
+ cb(error);
+ return;
+ }
+
+ this._opcode = this._fragmented;
+ } else if (this._opcode === 0x01 || this._opcode === 0x02) {
+ if (this._fragmented) {
+ const error = this.createError(
+ RangeError,
+ `invalid opcode ${this._opcode}`,
+ true,
+ 1002,
+ 'WS_ERR_INVALID_OPCODE'
+ );
+
+ cb(error);
+ return;
+ }
+
+ this._compressed = compressed;
+ } else if (this._opcode > 0x07 && this._opcode < 0x0b) {
+ if (!this._fin) {
+ const error = this.createError(
+ RangeError,
+ 'FIN must be set',
+ true,
+ 1002,
+ 'WS_ERR_EXPECTED_FIN'
+ );
+
+ cb(error);
+ return;
+ }
+
+ if (compressed) {
+ const error = this.createError(
+ RangeError,
+ 'RSV1 must be clear',
+ true,
+ 1002,
+ 'WS_ERR_UNEXPECTED_RSV_1'
+ );
+
+ cb(error);
+ return;
+ }
+
+ if (
+ this._payloadLength > 0x7d ||
+ (this._opcode === 0x08 && this._payloadLength === 1)
+ ) {
+ const error = this.createError(
+ RangeError,
+ `invalid payload length ${this._payloadLength}`,
+ true,
+ 1002,
+ 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
+ );
+
+ cb(error);
+ return;
+ }
+ } else {
+ const error = this.createError(
+ RangeError,
+ `invalid opcode ${this._opcode}`,
+ true,
+ 1002,
+ 'WS_ERR_INVALID_OPCODE'
+ );
+
+ cb(error);
+ return;
+ }
+
+ if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
+ this._masked = (buf[1] & 0x80) === 0x80;
+
+ if (this._isServer) {
+ if (!this._masked) {
+ const error = this.createError(
+ RangeError,
+ 'MASK must be set',
+ true,
+ 1002,
+ 'WS_ERR_EXPECTED_MASK'
+ );
+
+ cb(error);
+ return;
+ }
+ } else if (this._masked) {
+ const error = this.createError(
+ RangeError,
+ 'MASK must be clear',
+ true,
+ 1002,
+ 'WS_ERR_UNEXPECTED_MASK'
+ );
+
+ cb(error);
+ return;
+ }
+
+ if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
+ else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
+ else this.haveLength(cb);
+ }
+
+ /**
+ * Gets extended payload length (7+16).
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ getPayloadLength16(cb) {
+ if (this._bufferedBytes < 2) {
+ this._loop = false;
+ return;
+ }
+
+ this._payloadLength = this.consume(2).readUInt16BE(0);
+ this.haveLength(cb);
+ }
+
+ /**
+ * Gets extended payload length (7+64).
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ getPayloadLength64(cb) {
+ if (this._bufferedBytes < 8) {
+ this._loop = false;
+ return;
+ }
+
+ const buf = this.consume(8);
+ const num = buf.readUInt32BE(0);
+
+ //
+ // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
+ // if payload length is greater than this number.
+ //
+ if (num > Math.pow(2, 53 - 32) - 1) {
+ const error = this.createError(
+ RangeError,
+ 'Unsupported WebSocket frame: payload length > 2^53 - 1',
+ false,
+ 1009,
+ 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
+ );
+
+ cb(error);
+ return;
+ }
+
+ this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
+ this.haveLength(cb);
+ }
+
+ /**
+ * Payload length has been read.
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ haveLength(cb) {
+ if (this._payloadLength && this._opcode < 0x08) {
+ this._totalPayloadLength += this._payloadLength;
+ if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
+ const error = this.createError(
+ RangeError,
+ 'Max payload size exceeded',
+ false,
+ 1009,
+ 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
+ );
+
+ cb(error);
+ return;
+ }
+ }
+
+ if (this._masked) this._state = GET_MASK;
+ else this._state = GET_DATA;
+ }
+
+ /**
+ * Reads mask bytes.
+ *
+ * @private
+ */
+ getMask() {
+ if (this._bufferedBytes < 4) {
+ this._loop = false;
+ return;
+ }
+
+ this._mask = this.consume(4);
+ this._state = GET_DATA;
+ }
+
+ /**
+ * Reads data bytes.
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ getData(cb) {
+ let data = EMPTY_BUFFER;
+
+ if (this._payloadLength) {
+ if (this._bufferedBytes < this._payloadLength) {
+ this._loop = false;
+ return;
+ }
+
+ data = this.consume(this._payloadLength);
+
+ if (
+ this._masked &&
+ (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
+ ) {
+ unmask(data, this._mask);
+ }
+ }
+
+ if (this._opcode > 0x07) {
+ this.controlMessage(data, cb);
+ return;
+ }
+
+ if (this._compressed) {
+ this._state = INFLATING;
+ this.decompress(data, cb);
+ return;
+ }
+
+ if (data.length) {
+ //
+ // This message is not compressed so its length is the sum of the payload
+ // length of all fragments.
+ //
+ this._messageLength = this._totalPayloadLength;
+ this._fragments.push(data);
+ }
+
+ this.dataMessage(cb);
+ }
+
+ /**
+ * Decompresses data.
+ *
+ * @param {Buffer} data Compressed data
+ * @param {Function} cb Callback
+ * @private
+ */
+ decompress(data, cb) {
+ const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
+
+ perMessageDeflate.decompress(data, this._fin, (err, buf) => {
+ if (err) return cb(err);
+
+ if (buf.length) {
+ this._messageLength += buf.length;
+ if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
+ const error = this.createError(
+ RangeError,
+ 'Max payload size exceeded',
+ false,
+ 1009,
+ 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
+ );
+
+ cb(error);
+ return;
+ }
+
+ this._fragments.push(buf);
+ }
+
+ this.dataMessage(cb);
+ if (this._state === GET_INFO) this.startLoop(cb);
+ });
+ }
+
+ /**
+ * Handles a data message.
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ dataMessage(cb) {
+ if (!this._fin) {
+ this._state = GET_INFO;
+ return;
+ }
+
+ const messageLength = this._messageLength;
+ const fragments = this._fragments;
+
+ this._totalPayloadLength = 0;
+ this._messageLength = 0;
+ this._fragmented = 0;
+ this._fragments = [];
+
+ if (this._opcode === 2) {
+ let data;
+
+ if (this._binaryType === 'nodebuffer') {
+ data = concat(fragments, messageLength);
+ } else if (this._binaryType === 'arraybuffer') {
+ data = toArrayBuffer(concat(fragments, messageLength));
+ } else if (this._binaryType === 'blob') {
+ data = new Blob(fragments);
+ } else {
+ data = fragments;
+ }
+
+ if (this._allowSynchronousEvents) {
+ this.emit('message', data, true);
+ this._state = GET_INFO;
+ } else {
+ this._state = DEFER_EVENT;
+ setImmediate(() => {
+ this.emit('message', data, true);
+ this._state = GET_INFO;
+ this.startLoop(cb);
+ });
+ }
+ } else {
+ const buf = concat(fragments, messageLength);
+
+ if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
+ const error = this.createError(
+ Error,
+ 'invalid UTF-8 sequence',
+ true,
+ 1007,
+ 'WS_ERR_INVALID_UTF8'
+ );
+
+ cb(error);
+ return;
+ }
+
+ if (this._state === INFLATING || this._allowSynchronousEvents) {
+ this.emit('message', buf, false);
+ this._state = GET_INFO;
+ } else {
+ this._state = DEFER_EVENT;
+ setImmediate(() => {
+ this.emit('message', buf, false);
+ this._state = GET_INFO;
+ this.startLoop(cb);
+ });
+ }
+ }
+ }
+
+ /**
+ * Handles a control message.
+ *
+ * @param {Buffer} data Data to handle
+ * @return {(Error|RangeError|undefined)} A possible error
+ * @private
+ */
+ controlMessage(data, cb) {
+ if (this._opcode === 0x08) {
+ if (data.length === 0) {
+ this._loop = false;
+ this.emit('conclude', 1005, EMPTY_BUFFER);
+ this.end();
+ } else {
+ const code = data.readUInt16BE(0);
+
+ if (!isValidStatusCode(code)) {
+ const error = this.createError(
+ RangeError,
+ `invalid status code ${code}`,
+ true,
+ 1002,
+ 'WS_ERR_INVALID_CLOSE_CODE'
+ );
+
+ cb(error);
+ return;
+ }
+
+ const buf = new FastBuffer(
+ data.buffer,
+ data.byteOffset + 2,
+ data.length - 2
+ );
+
+ if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
+ const error = this.createError(
+ Error,
+ 'invalid UTF-8 sequence',
+ true,
+ 1007,
+ 'WS_ERR_INVALID_UTF8'
+ );
+
+ cb(error);
+ return;
+ }
+
+ this._loop = false;
+ this.emit('conclude', code, buf);
+ this.end();
+ }
+
+ this._state = GET_INFO;
+ return;
+ }
+
+ if (this._allowSynchronousEvents) {
+ this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
+ this._state = GET_INFO;
+ } else {
+ this._state = DEFER_EVENT;
+ setImmediate(() => {
+ this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
+ this._state = GET_INFO;
+ this.startLoop(cb);
+ });
+ }
+ }
+
+ /**
+ * Builds an error object.
+ *
+ * @param {function(new:Error|RangeError)} ErrorCtor The error constructor
+ * @param {String} message The error message
+ * @param {Boolean} prefix Specifies whether or not to add a default prefix to
+ * `message`
+ * @param {Number} statusCode The status code
+ * @param {String} errorCode The exposed error code
+ * @return {(Error|RangeError)} The error
+ * @private
+ */
+ createError(ErrorCtor, message, prefix, statusCode, errorCode) {
+ this._loop = false;
+ this._errored = true;
+
+ const err = new ErrorCtor(
+ prefix ? `Invalid WebSocket frame: ${message}` : message
+ );
+
+ Error.captureStackTrace(err, this.createError);
+ err.code = errorCode;
+ err[kStatusCode] = statusCode;
+ return err;
+ }
+}
+
+module.exports = Receiver;
diff --git a/node_modules/ws/lib/sender.js b/node_modules/ws/lib/sender.js
new file mode 100644
index 0000000000..a8b1da3a99
--- /dev/null
+++ b/node_modules/ws/lib/sender.js
@@ -0,0 +1,602 @@
+/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
+
+'use strict';
+
+const { Duplex } = require('stream');
+const { randomFillSync } = require('crypto');
+
+const PerMessageDeflate = require('./permessage-deflate');
+const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
+const { isBlob, isValidStatusCode } = require('./validation');
+const { mask: applyMask, toBuffer } = require('./buffer-util');
+
+const kByteLength = Symbol('kByteLength');
+const maskBuffer = Buffer.alloc(4);
+const RANDOM_POOL_SIZE = 8 * 1024;
+let randomPool;
+let randomPoolPointer = RANDOM_POOL_SIZE;
+
+const DEFAULT = 0;
+const DEFLATING = 1;
+const GET_BLOB_DATA = 2;
+
+/**
+ * HyBi Sender implementation.
+ */
+class Sender {
+ /**
+ * Creates a Sender instance.
+ *
+ * @param {Duplex} socket The connection socket
+ * @param {Object} [extensions] An object containing the negotiated extensions
+ * @param {Function} [generateMask] The function used to generate the masking
+ * key
+ */
+ constructor(socket, extensions, generateMask) {
+ this._extensions = extensions || {};
+
+ if (generateMask) {
+ this._generateMask = generateMask;
+ this._maskBuffer = Buffer.alloc(4);
+ }
+
+ this._socket = socket;
+
+ this._firstFragment = true;
+ this._compress = false;
+
+ this._bufferedBytes = 0;
+ this._queue = [];
+ this._state = DEFAULT;
+ this.onerror = NOOP;
+ this[kWebSocket] = undefined;
+ }
+
+ /**
+ * Frames a piece of data according to the HyBi WebSocket protocol.
+ *
+ * @param {(Buffer|String)} data The data to frame
+ * @param {Object} options Options object
+ * @param {Boolean} [options.fin=false] Specifies whether or not to set the
+ * FIN bit
+ * @param {Function} [options.generateMask] The function used to generate the
+ * masking key
+ * @param {Boolean} [options.mask=false] Specifies whether or not to mask
+ * `data`
+ * @param {Buffer} [options.maskBuffer] The buffer used to store the masking
+ * key
+ * @param {Number} options.opcode The opcode
+ * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
+ * modified
+ * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
+ * RSV1 bit
+ * @return {(Buffer|String)[]} The framed data
+ * @public
+ */
+ static frame(data, options) {
+ let mask;
+ let merge = false;
+ let offset = 2;
+ let skipMasking = false;
+
+ if (options.mask) {
+ mask = options.maskBuffer || maskBuffer;
+
+ if (options.generateMask) {
+ options.generateMask(mask);
+ } else {
+ if (randomPoolPointer === RANDOM_POOL_SIZE) {
+ /* istanbul ignore else */
+ if (randomPool === undefined) {
+ //
+ // This is lazily initialized because server-sent frames must not
+ // be masked so it may never be used.
+ //
+ randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
+ }
+
+ randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
+ randomPoolPointer = 0;
+ }
+
+ mask[0] = randomPool[randomPoolPointer++];
+ mask[1] = randomPool[randomPoolPointer++];
+ mask[2] = randomPool[randomPoolPointer++];
+ mask[3] = randomPool[randomPoolPointer++];
+ }
+
+ skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
+ offset = 6;
+ }
+
+ let dataLength;
+
+ if (typeof data === 'string') {
+ if (
+ (!options.mask || skipMasking) &&
+ options[kByteLength] !== undefined
+ ) {
+ dataLength = options[kByteLength];
+ } else {
+ data = Buffer.from(data);
+ dataLength = data.length;
+ }
+ } else {
+ dataLength = data.length;
+ merge = options.mask && options.readOnly && !skipMasking;
+ }
+
+ let payloadLength = dataLength;
+
+ if (dataLength >= 65536) {
+ offset += 8;
+ payloadLength = 127;
+ } else if (dataLength > 125) {
+ offset += 2;
+ payloadLength = 126;
+ }
+
+ const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
+
+ target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
+ if (options.rsv1) target[0] |= 0x40;
+
+ target[1] = payloadLength;
+
+ if (payloadLength === 126) {
+ target.writeUInt16BE(dataLength, 2);
+ } else if (payloadLength === 127) {
+ target[2] = target[3] = 0;
+ target.writeUIntBE(dataLength, 4, 6);
+ }
+
+ if (!options.mask) return [target, data];
+
+ target[1] |= 0x80;
+ target[offset - 4] = mask[0];
+ target[offset - 3] = mask[1];
+ target[offset - 2] = mask[2];
+ target[offset - 1] = mask[3];
+
+ if (skipMasking) return [target, data];
+
+ if (merge) {
+ applyMask(data, mask, target, offset, dataLength);
+ return [target];
+ }
+
+ applyMask(data, mask, data, 0, dataLength);
+ return [target, data];
+ }
+
+ /**
+ * Sends a close message to the other peer.
+ *
+ * @param {Number} [code] The status code component of the body
+ * @param {(String|Buffer)} [data] The message component of the body
+ * @param {Boolean} [mask=false] Specifies whether or not to mask the message
+ * @param {Function} [cb] Callback
+ * @public
+ */
+ close(code, data, mask, cb) {
+ let buf;
+
+ if (code === undefined) {
+ buf = EMPTY_BUFFER;
+ } else if (typeof code !== 'number' || !isValidStatusCode(code)) {
+ throw new TypeError('First argument must be a valid error code number');
+ } else if (data === undefined || !data.length) {
+ buf = Buffer.allocUnsafe(2);
+ buf.writeUInt16BE(code, 0);
+ } else {
+ const length = Buffer.byteLength(data);
+
+ if (length > 123) {
+ throw new RangeError('The message must not be greater than 123 bytes');
+ }
+
+ buf = Buffer.allocUnsafe(2 + length);
+ buf.writeUInt16BE(code, 0);
+
+ if (typeof data === 'string') {
+ buf.write(data, 2);
+ } else {
+ buf.set(data, 2);
+ }
+ }
+
+ const options = {
+ [kByteLength]: buf.length,
+ fin: true,
+ generateMask: this._generateMask,
+ mask,
+ maskBuffer: this._maskBuffer,
+ opcode: 0x08,
+ readOnly: false,
+ rsv1: false
+ };
+
+ if (this._state !== DEFAULT) {
+ this.enqueue([this.dispatch, buf, false, options, cb]);
+ } else {
+ this.sendFrame(Sender.frame(buf, options), cb);
+ }
+ }
+
+ /**
+ * Sends a ping message to the other peer.
+ *
+ * @param {*} data The message to send
+ * @param {Boolean} [mask=false] Specifies whether or not to mask `data`
+ * @param {Function} [cb] Callback
+ * @public
+ */
+ ping(data, mask, cb) {
+ let byteLength;
+ let readOnly;
+
+ if (typeof data === 'string') {
+ byteLength = Buffer.byteLength(data);
+ readOnly = false;
+ } else if (isBlob(data)) {
+ byteLength = data.size;
+ readOnly = false;
+ } else {
+ data = toBuffer(data);
+ byteLength = data.length;
+ readOnly = toBuffer.readOnly;
+ }
+
+ if (byteLength > 125) {
+ throw new RangeError('The data size must not be greater than 125 bytes');
+ }
+
+ const options = {
+ [kByteLength]: byteLength,
+ fin: true,
+ generateMask: this._generateMask,
+ mask,
+ maskBuffer: this._maskBuffer,
+ opcode: 0x09,
+ readOnly,
+ rsv1: false
+ };
+
+ if (isBlob(data)) {
+ if (this._state !== DEFAULT) {
+ this.enqueue([this.getBlobData, data, false, options, cb]);
+ } else {
+ this.getBlobData(data, false, options, cb);
+ }
+ } else if (this._state !== DEFAULT) {
+ this.enqueue([this.dispatch, data, false, options, cb]);
+ } else {
+ this.sendFrame(Sender.frame(data, options), cb);
+ }
+ }
+
+ /**
+ * Sends a pong message to the other peer.
+ *
+ * @param {*} data The message to send
+ * @param {Boolean} [mask=false] Specifies whether or not to mask `data`
+ * @param {Function} [cb] Callback
+ * @public
+ */
+ pong(data, mask, cb) {
+ let byteLength;
+ let readOnly;
+
+ if (typeof data === 'string') {
+ byteLength = Buffer.byteLength(data);
+ readOnly = false;
+ } else if (isBlob(data)) {
+ byteLength = data.size;
+ readOnly = false;
+ } else {
+ data = toBuffer(data);
+ byteLength = data.length;
+ readOnly = toBuffer.readOnly;
+ }
+
+ if (byteLength > 125) {
+ throw new RangeError('The data size must not be greater than 125 bytes');
+ }
+
+ const options = {
+ [kByteLength]: byteLength,
+ fin: true,
+ generateMask: this._generateMask,
+ mask,
+ maskBuffer: this._maskBuffer,
+ opcode: 0x0a,
+ readOnly,
+ rsv1: false
+ };
+
+ if (isBlob(data)) {
+ if (this._state !== DEFAULT) {
+ this.enqueue([this.getBlobData, data, false, options, cb]);
+ } else {
+ this.getBlobData(data, false, options, cb);
+ }
+ } else if (this._state !== DEFAULT) {
+ this.enqueue([this.dispatch, data, false, options, cb]);
+ } else {
+ this.sendFrame(Sender.frame(data, options), cb);
+ }
+ }
+
+ /**
+ * Sends a data message to the other peer.
+ *
+ * @param {*} data The message to send
+ * @param {Object} options Options object
+ * @param {Boolean} [options.binary=false] Specifies whether `data` is binary
+ * or text
+ * @param {Boolean} [options.compress=false] Specifies whether or not to
+ * compress `data`
+ * @param {Boolean} [options.fin=false] Specifies whether the fragment is the
+ * last one
+ * @param {Boolean} [options.mask=false] Specifies whether or not to mask
+ * `data`
+ * @param {Function} [cb] Callback
+ * @public
+ */
+ send(data, options, cb) {
+ const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
+ let opcode = options.binary ? 2 : 1;
+ let rsv1 = options.compress;
+
+ let byteLength;
+ let readOnly;
+
+ if (typeof data === 'string') {
+ byteLength = Buffer.byteLength(data);
+ readOnly = false;
+ } else if (isBlob(data)) {
+ byteLength = data.size;
+ readOnly = false;
+ } else {
+ data = toBuffer(data);
+ byteLength = data.length;
+ readOnly = toBuffer.readOnly;
+ }
+
+ if (this._firstFragment) {
+ this._firstFragment = false;
+ if (
+ rsv1 &&
+ perMessageDeflate &&
+ perMessageDeflate.params[
+ perMessageDeflate._isServer
+ ? 'server_no_context_takeover'
+ : 'client_no_context_takeover'
+ ]
+ ) {
+ rsv1 = byteLength >= perMessageDeflate._threshold;
+ }
+ this._compress = rsv1;
+ } else {
+ rsv1 = false;
+ opcode = 0;
+ }
+
+ if (options.fin) this._firstFragment = true;
+
+ const opts = {
+ [kByteLength]: byteLength,
+ fin: options.fin,
+ generateMask: this._generateMask,
+ mask: options.mask,
+ maskBuffer: this._maskBuffer,
+ opcode,
+ readOnly,
+ rsv1
+ };
+
+ if (isBlob(data)) {
+ if (this._state !== DEFAULT) {
+ this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
+ } else {
+ this.getBlobData(data, this._compress, opts, cb);
+ }
+ } else if (this._state !== DEFAULT) {
+ this.enqueue([this.dispatch, data, this._compress, opts, cb]);
+ } else {
+ this.dispatch(data, this._compress, opts, cb);
+ }
+ }
+
+ /**
+ * Gets the contents of a blob as binary data.
+ *
+ * @param {Blob} blob The blob
+ * @param {Boolean} [compress=false] Specifies whether or not to compress
+ * the data
+ * @param {Object} options Options object
+ * @param {Boolean} [options.fin=false] Specifies whether or not to set the
+ * FIN bit
+ * @param {Function} [options.generateMask] The function used to generate the
+ * masking key
+ * @param {Boolean} [options.mask=false] Specifies whether or not to mask
+ * `data`
+ * @param {Buffer} [options.maskBuffer] The buffer used to store the masking
+ * key
+ * @param {Number} options.opcode The opcode
+ * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
+ * modified
+ * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
+ * RSV1 bit
+ * @param {Function} [cb] Callback
+ * @private
+ */
+ getBlobData(blob, compress, options, cb) {
+ this._bufferedBytes += options[kByteLength];
+ this._state = GET_BLOB_DATA;
+
+ blob
+ .arrayBuffer()
+ .then((arrayBuffer) => {
+ if (this._socket.destroyed) {
+ const err = new Error(
+ 'The socket was closed while the blob was being read'
+ );
+
+ //
+ // `callCallbacks` is called in the next tick to ensure that errors
+ // that might be thrown in the callbacks behave like errors thrown
+ // outside the promise chain.
+ //
+ process.nextTick(callCallbacks, this, err, cb);
+ return;
+ }
+
+ this._bufferedBytes -= options[kByteLength];
+ const data = toBuffer(arrayBuffer);
+
+ if (!compress) {
+ this._state = DEFAULT;
+ this.sendFrame(Sender.frame(data, options), cb);
+ this.dequeue();
+ } else {
+ this.dispatch(data, compress, options, cb);
+ }
+ })
+ .catch((err) => {
+ //
+ // `onError` is called in the next tick for the same reason that
+ // `callCallbacks` above is.
+ //
+ process.nextTick(onError, this, err, cb);
+ });
+ }
+
+ /**
+ * Dispatches a message.
+ *
+ * @param {(Buffer|String)} data The message to send
+ * @param {Boolean} [compress=false] Specifies whether or not to compress
+ * `data`
+ * @param {Object} options Options object
+ * @param {Boolean} [options.fin=false] Specifies whether or not to set the
+ * FIN bit
+ * @param {Function} [options.generateMask] The function used to generate the
+ * masking key
+ * @param {Boolean} [options.mask=false] Specifies whether or not to mask
+ * `data`
+ * @param {Buffer} [options.maskBuffer] The buffer used to store the masking
+ * key
+ * @param {Number} options.opcode The opcode
+ * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
+ * modified
+ * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
+ * RSV1 bit
+ * @param {Function} [cb] Callback
+ * @private
+ */
+ dispatch(data, compress, options, cb) {
+ if (!compress) {
+ this.sendFrame(Sender.frame(data, options), cb);
+ return;
+ }
+
+ const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
+
+ this._bufferedBytes += options[kByteLength];
+ this._state = DEFLATING;
+ perMessageDeflate.compress(data, options.fin, (_, buf) => {
+ if (this._socket.destroyed) {
+ const err = new Error(
+ 'The socket was closed while data was being compressed'
+ );
+
+ callCallbacks(this, err, cb);
+ return;
+ }
+
+ this._bufferedBytes -= options[kByteLength];
+ this._state = DEFAULT;
+ options.readOnly = false;
+ this.sendFrame(Sender.frame(buf, options), cb);
+ this.dequeue();
+ });
+ }
+
+ /**
+ * Executes queued send operations.
+ *
+ * @private
+ */
+ dequeue() {
+ while (this._state === DEFAULT && this._queue.length) {
+ const params = this._queue.shift();
+
+ this._bufferedBytes -= params[3][kByteLength];
+ Reflect.apply(params[0], this, params.slice(1));
+ }
+ }
+
+ /**
+ * Enqueues a send operation.
+ *
+ * @param {Array} params Send operation parameters.
+ * @private
+ */
+ enqueue(params) {
+ this._bufferedBytes += params[3][kByteLength];
+ this._queue.push(params);
+ }
+
+ /**
+ * Sends a frame.
+ *
+ * @param {(Buffer | String)[]} list The frame to send
+ * @param {Function} [cb] Callback
+ * @private
+ */
+ sendFrame(list, cb) {
+ if (list.length === 2) {
+ this._socket.cork();
+ this._socket.write(list[0]);
+ this._socket.write(list[1], cb);
+ this._socket.uncork();
+ } else {
+ this._socket.write(list[0], cb);
+ }
+ }
+}
+
+module.exports = Sender;
+
+/**
+ * Calls queued callbacks with an error.
+ *
+ * @param {Sender} sender The `Sender` instance
+ * @param {Error} err The error to call the callbacks with
+ * @param {Function} [cb] The first callback
+ * @private
+ */
+function callCallbacks(sender, err, cb) {
+ if (typeof cb === 'function') cb(err);
+
+ for (let i = 0; i < sender._queue.length; i++) {
+ const params = sender._queue[i];
+ const callback = params[params.length - 1];
+
+ if (typeof callback === 'function') callback(err);
+ }
+}
+
+/**
+ * Handles a `Sender` error.
+ *
+ * @param {Sender} sender The `Sender` instance
+ * @param {Error} err The error
+ * @param {Function} [cb] The first pending callback
+ * @private
+ */
+function onError(sender, err, cb) {
+ callCallbacks(sender, err, cb);
+ sender.onerror(err);
+}
diff --git a/node_modules/ws/lib/stream.js b/node_modules/ws/lib/stream.js
new file mode 100644
index 0000000000..4c58c911bc
--- /dev/null
+++ b/node_modules/ws/lib/stream.js
@@ -0,0 +1,161 @@
+/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
+'use strict';
+
+const WebSocket = require('./websocket');
+const { Duplex } = require('stream');
+
+/**
+ * Emits the `'close'` event on a stream.
+ *
+ * @param {Duplex} stream The stream.
+ * @private
+ */
+function emitClose(stream) {
+ stream.emit('close');
+}
+
+/**
+ * The listener of the `'end'` event.
+ *
+ * @private
+ */
+function duplexOnEnd() {
+ if (!this.destroyed && this._writableState.finished) {
+ this.destroy();
+ }
+}
+
+/**
+ * The listener of the `'error'` event.
+ *
+ * @param {Error} err The error
+ * @private
+ */
+function duplexOnError(err) {
+ this.removeListener('error', duplexOnError);
+ this.destroy();
+ if (this.listenerCount('error') === 0) {
+ // Do not suppress the throwing behavior.
+ this.emit('error', err);
+ }
+}
+
+/**
+ * Wraps a `WebSocket` in a duplex stream.
+ *
+ * @param {WebSocket} ws The `WebSocket` to wrap
+ * @param {Object} [options] The options for the `Duplex` constructor
+ * @return {Duplex} The duplex stream
+ * @public
+ */
+function createWebSocketStream(ws, options) {
+ let terminateOnDestroy = true;
+
+ const duplex = new Duplex({
+ ...options,
+ autoDestroy: false,
+ emitClose: false,
+ objectMode: false,
+ writableObjectMode: false
+ });
+
+ ws.on('message', function message(msg, isBinary) {
+ const data =
+ !isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
+
+ if (!duplex.push(data)) ws.pause();
+ });
+
+ ws.once('error', function error(err) {
+ if (duplex.destroyed) return;
+
+ // Prevent `ws.terminate()` from being called by `duplex._destroy()`.
+ //
+ // - If the `'error'` event is emitted before the `'open'` event, then
+ // `ws.terminate()` is a noop as no socket is assigned.
+ // - Otherwise, the error is re-emitted by the listener of the `'error'`
+ // event of the `Receiver` object. The listener already closes the
+ // connection by calling `ws.close()`. This allows a close frame to be
+ // sent to the other peer. If `ws.terminate()` is called right after this,
+ // then the close frame might not be sent.
+ terminateOnDestroy = false;
+ duplex.destroy(err);
+ });
+
+ ws.once('close', function close() {
+ if (duplex.destroyed) return;
+
+ duplex.push(null);
+ });
+
+ duplex._destroy = function (err, callback) {
+ if (ws.readyState === ws.CLOSED) {
+ callback(err);
+ process.nextTick(emitClose, duplex);
+ return;
+ }
+
+ let called = false;
+
+ ws.once('error', function error(err) {
+ called = true;
+ callback(err);
+ });
+
+ ws.once('close', function close() {
+ if (!called) callback(err);
+ process.nextTick(emitClose, duplex);
+ });
+
+ if (terminateOnDestroy) ws.terminate();
+ };
+
+ duplex._final = function (callback) {
+ if (ws.readyState === ws.CONNECTING) {
+ ws.once('open', function open() {
+ duplex._final(callback);
+ });
+ return;
+ }
+
+ // If the value of the `_socket` property is `null` it means that `ws` is a
+ // client websocket and the handshake failed. In fact, when this happens, a
+ // socket is never assigned to the websocket. Wait for the `'error'` event
+ // that will be emitted by the websocket.
+ if (ws._socket === null) return;
+
+ if (ws._socket._writableState.finished) {
+ callback();
+ if (duplex._readableState.endEmitted) duplex.destroy();
+ } else {
+ ws._socket.once('finish', function finish() {
+ // `duplex` is not destroyed here because the `'end'` event will be
+ // emitted on `duplex` after this `'finish'` event. The EOF signaling
+ // `null` chunk is, in fact, pushed when the websocket emits `'close'`.
+ callback();
+ });
+ ws.close();
+ }
+ };
+
+ duplex._read = function () {
+ if (ws.isPaused) ws.resume();
+ };
+
+ duplex._write = function (chunk, encoding, callback) {
+ if (ws.readyState === ws.CONNECTING) {
+ ws.once('open', function open() {
+ duplex._write(chunk, encoding, callback);
+ });
+ return;
+ }
+
+ ws.send(chunk, callback);
+ };
+
+ duplex.on('end', duplexOnEnd);
+ duplex.on('error', duplexOnError);
+ return duplex;
+}
+
+module.exports = createWebSocketStream;
diff --git a/node_modules/ws/lib/subprotocol.js b/node_modules/ws/lib/subprotocol.js
new file mode 100644
index 0000000000..d4381e8864
--- /dev/null
+++ b/node_modules/ws/lib/subprotocol.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const { tokenChars } = require('./validation');
+
+/**
+ * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
+ *
+ * @param {String} header The field value of the header
+ * @return {Set} The subprotocol names
+ * @public
+ */
+function parse(header) {
+ const protocols = new Set();
+ let start = -1;
+ let end = -1;
+ let i = 0;
+
+ for (i; i < header.length; i++) {
+ const code = header.charCodeAt(i);
+
+ if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (
+ i !== 0 &&
+ (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
+ ) {
+ if (end === -1 && start !== -1) end = i;
+ } else if (code === 0x2c /* ',' */) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+
+ const protocol = header.slice(start, end);
+
+ if (protocols.has(protocol)) {
+ throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
+ }
+
+ protocols.add(protocol);
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ }
+
+ if (start === -1 || end !== -1) {
+ throw new SyntaxError('Unexpected end of input');
+ }
+
+ const protocol = header.slice(start, i);
+
+ if (protocols.has(protocol)) {
+ throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
+ }
+
+ protocols.add(protocol);
+ return protocols;
+}
+
+module.exports = { parse };
diff --git a/node_modules/ws/lib/validation.js b/node_modules/ws/lib/validation.js
new file mode 100644
index 0000000000..4a2e68d512
--- /dev/null
+++ b/node_modules/ws/lib/validation.js
@@ -0,0 +1,152 @@
+'use strict';
+
+const { isUtf8 } = require('buffer');
+
+const { hasBlob } = require('./constants');
+
+//
+// Allowed token characters:
+//
+// '!', '#', '$', '%', '&', ''', '*', '+', '-',
+// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
+//
+// tokenChars[32] === 0 // ' '
+// tokenChars[33] === 1 // '!'
+// tokenChars[34] === 0 // '"'
+// ...
+//
+// prettier-ignore
+const tokenChars = [
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
+ 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
+ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
+];
+
+/**
+ * Checks if a status code is allowed in a close frame.
+ *
+ * @param {Number} code The status code
+ * @return {Boolean} `true` if the status code is valid, else `false`
+ * @public
+ */
+function isValidStatusCode(code) {
+ return (
+ (code >= 1000 &&
+ code <= 1014 &&
+ code !== 1004 &&
+ code !== 1005 &&
+ code !== 1006) ||
+ (code >= 3000 && code <= 4999)
+ );
+}
+
+/**
+ * Checks if a given buffer contains only correct UTF-8.
+ * Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
+ * Markus Kuhn.
+ *
+ * @param {Buffer} buf The buffer to check
+ * @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
+ * @public
+ */
+function _isValidUTF8(buf) {
+ const len = buf.length;
+ let i = 0;
+
+ while (i < len) {
+ if ((buf[i] & 0x80) === 0) {
+ // 0xxxxxxx
+ i++;
+ } else if ((buf[i] & 0xe0) === 0xc0) {
+ // 110xxxxx 10xxxxxx
+ if (
+ i + 1 === len ||
+ (buf[i + 1] & 0xc0) !== 0x80 ||
+ (buf[i] & 0xfe) === 0xc0 // Overlong
+ ) {
+ return false;
+ }
+
+ i += 2;
+ } else if ((buf[i] & 0xf0) === 0xe0) {
+ // 1110xxxx 10xxxxxx 10xxxxxx
+ if (
+ i + 2 >= len ||
+ (buf[i + 1] & 0xc0) !== 0x80 ||
+ (buf[i + 2] & 0xc0) !== 0x80 ||
+ (buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
+ (buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
+ ) {
+ return false;
+ }
+
+ i += 3;
+ } else if ((buf[i] & 0xf8) === 0xf0) {
+ // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
+ if (
+ i + 3 >= len ||
+ (buf[i + 1] & 0xc0) !== 0x80 ||
+ (buf[i + 2] & 0xc0) !== 0x80 ||
+ (buf[i + 3] & 0xc0) !== 0x80 ||
+ (buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
+ (buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
+ buf[i] > 0xf4 // > U+10FFFF
+ ) {
+ return false;
+ }
+
+ i += 4;
+ } else {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Determines whether a value is a `Blob`.
+ *
+ * @param {*} value The value to be tested
+ * @return {Boolean} `true` if `value` is a `Blob`, else `false`
+ * @private
+ */
+function isBlob(value) {
+ return (
+ hasBlob &&
+ typeof value === 'object' &&
+ typeof value.arrayBuffer === 'function' &&
+ typeof value.type === 'string' &&
+ typeof value.stream === 'function' &&
+ (value[Symbol.toStringTag] === 'Blob' ||
+ value[Symbol.toStringTag] === 'File')
+ );
+}
+
+module.exports = {
+ isBlob,
+ isValidStatusCode,
+ isValidUTF8: _isValidUTF8,
+ tokenChars
+};
+
+if (isUtf8) {
+ module.exports.isValidUTF8 = function (buf) {
+ return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
+ };
+} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
+ try {
+ const isValidUTF8 = require('utf-8-validate');
+
+ module.exports.isValidUTF8 = function (buf) {
+ return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
+ };
+ } catch (e) {
+ // Continue regardless of the error.
+ }
+}
diff --git a/node_modules/ws/lib/websocket-server.js b/node_modules/ws/lib/websocket-server.js
new file mode 100644
index 0000000000..68aa7897d5
--- /dev/null
+++ b/node_modules/ws/lib/websocket-server.js
@@ -0,0 +1,554 @@
+/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
+
+'use strict';
+
+const EventEmitter = require('events');
+const http = require('http');
+const { Duplex } = require('stream');
+const { createHash } = require('crypto');
+
+const extension = require('./extension');
+const PerMessageDeflate = require('./permessage-deflate');
+const subprotocol = require('./subprotocol');
+const WebSocket = require('./websocket');
+const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants');
+
+const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
+
+const RUNNING = 0;
+const CLOSING = 1;
+const CLOSED = 2;
+
+/**
+ * Class representing a WebSocket server.
+ *
+ * @extends EventEmitter
+ */
+class WebSocketServer extends EventEmitter {
+ /**
+ * Create a `WebSocketServer` instance.
+ *
+ * @param {Object} options Configuration options
+ * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
+ * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
+ * multiple times in the same tick
+ * @param {Boolean} [options.autoPong=true] Specifies whether or not to
+ * automatically send a pong in response to a ping
+ * @param {Number} [options.backlog=511] The maximum length of the queue of
+ * pending connections
+ * @param {Boolean} [options.clientTracking=true] Specifies whether or not to
+ * track clients
+ * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
+ * wait for the closing handshake to finish after `websocket.close()` is
+ * called
+ * @param {Function} [options.handleProtocols] A hook to handle protocols
+ * @param {String} [options.host] The hostname where to bind the server
+ * @param {Number} [options.maxPayload=104857600] The maximum allowed message
+ * size
+ * @param {Boolean} [options.noServer=false] Enable no server mode
+ * @param {String} [options.path] Accept only connections matching this path
+ * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
+ * permessage-deflate
+ * @param {Number} [options.port] The port where to bind the server
+ * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
+ * server to use
+ * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
+ * not to skip UTF-8 validation for text and close messages
+ * @param {Function} [options.verifyClient] A hook to reject connections
+ * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
+ * class to use. It must be the `WebSocket` class or class that extends it
+ * @param {Function} [callback] A listener for the `listening` event
+ */
+ constructor(options, callback) {
+ super();
+
+ options = {
+ allowSynchronousEvents: true,
+ autoPong: true,
+ maxPayload: 100 * 1024 * 1024,
+ skipUTF8Validation: false,
+ perMessageDeflate: false,
+ handleProtocols: null,
+ clientTracking: true,
+ closeTimeout: CLOSE_TIMEOUT,
+ verifyClient: null,
+ noServer: false,
+ backlog: null, // use default (511 as implemented in net.js)
+ server: null,
+ host: null,
+ path: null,
+ port: null,
+ WebSocket,
+ ...options
+ };
+
+ if (
+ (options.port == null && !options.server && !options.noServer) ||
+ (options.port != null && (options.server || options.noServer)) ||
+ (options.server && options.noServer)
+ ) {
+ throw new TypeError(
+ 'One and only one of the "port", "server", or "noServer" options ' +
+ 'must be specified'
+ );
+ }
+
+ if (options.port != null) {
+ this._server = http.createServer((req, res) => {
+ const body = http.STATUS_CODES[426];
+
+ res.writeHead(426, {
+ 'Content-Length': body.length,
+ 'Content-Type': 'text/plain'
+ });
+ res.end(body);
+ });
+ this._server.listen(
+ options.port,
+ options.host,
+ options.backlog,
+ callback
+ );
+ } else if (options.server) {
+ this._server = options.server;
+ }
+
+ if (this._server) {
+ const emitConnection = this.emit.bind(this, 'connection');
+
+ this._removeListeners = addListeners(this._server, {
+ listening: this.emit.bind(this, 'listening'),
+ error: this.emit.bind(this, 'error'),
+ upgrade: (req, socket, head) => {
+ this.handleUpgrade(req, socket, head, emitConnection);
+ }
+ });
+ }
+
+ if (options.perMessageDeflate === true) options.perMessageDeflate = {};
+ if (options.clientTracking) {
+ this.clients = new Set();
+ this._shouldEmitClose = false;
+ }
+
+ this.options = options;
+ this._state = RUNNING;
+ }
+
+ /**
+ * Returns the bound address, the address family name, and port of the server
+ * as reported by the operating system if listening on an IP socket.
+ * If the server is listening on a pipe or UNIX domain socket, the name is
+ * returned as a string.
+ *
+ * @return {(Object|String|null)} The address of the server
+ * @public
+ */
+ address() {
+ if (this.options.noServer) {
+ throw new Error('The server is operating in "noServer" mode');
+ }
+
+ if (!this._server) return null;
+ return this._server.address();
+ }
+
+ /**
+ * Stop the server from accepting new connections and emit the `'close'` event
+ * when all existing connections are closed.
+ *
+ * @param {Function} [cb] A one-time listener for the `'close'` event
+ * @public
+ */
+ close(cb) {
+ if (this._state === CLOSED) {
+ if (cb) {
+ this.once('close', () => {
+ cb(new Error('The server is not running'));
+ });
+ }
+
+ process.nextTick(emitClose, this);
+ return;
+ }
+
+ if (cb) this.once('close', cb);
+
+ if (this._state === CLOSING) return;
+ this._state = CLOSING;
+
+ if (this.options.noServer || this.options.server) {
+ if (this._server) {
+ this._removeListeners();
+ this._removeListeners = this._server = null;
+ }
+
+ if (this.clients) {
+ if (!this.clients.size) {
+ process.nextTick(emitClose, this);
+ } else {
+ this._shouldEmitClose = true;
+ }
+ } else {
+ process.nextTick(emitClose, this);
+ }
+ } else {
+ const server = this._server;
+
+ this._removeListeners();
+ this._removeListeners = this._server = null;
+
+ //
+ // The HTTP/S server was created internally. Close it, and rely on its
+ // `'close'` event.
+ //
+ server.close(() => {
+ emitClose(this);
+ });
+ }
+ }
+
+ /**
+ * See if a given request should be handled by this server instance.
+ *
+ * @param {http.IncomingMessage} req Request object to inspect
+ * @return {Boolean} `true` if the request is valid, else `false`
+ * @public
+ */
+ shouldHandle(req) {
+ if (this.options.path) {
+ const index = req.url.indexOf('?');
+ const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
+
+ if (pathname !== this.options.path) return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle a HTTP Upgrade request.
+ *
+ * @param {http.IncomingMessage} req The request object
+ * @param {Duplex} socket The network socket between the server and client
+ * @param {Buffer} head The first packet of the upgraded stream
+ * @param {Function} cb Callback
+ * @public
+ */
+ handleUpgrade(req, socket, head, cb) {
+ socket.on('error', socketOnError);
+
+ const key = req.headers['sec-websocket-key'];
+ const upgrade = req.headers.upgrade;
+ const version = +req.headers['sec-websocket-version'];
+
+ if (req.method !== 'GET') {
+ const message = 'Invalid HTTP method';
+ abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
+ return;
+ }
+
+ if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
+ const message = 'Invalid Upgrade header';
+ abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
+ return;
+ }
+
+ if (key === undefined || !keyRegex.test(key)) {
+ const message = 'Missing or invalid Sec-WebSocket-Key header';
+ abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
+ return;
+ }
+
+ if (version !== 13 && version !== 8) {
+ const message = 'Missing or invalid Sec-WebSocket-Version header';
+ abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
+ 'Sec-WebSocket-Version': '13, 8'
+ });
+ return;
+ }
+
+ if (!this.shouldHandle(req)) {
+ abortHandshake(socket, 400);
+ return;
+ }
+
+ const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
+ let protocols = new Set();
+
+ if (secWebSocketProtocol !== undefined) {
+ try {
+ protocols = subprotocol.parse(secWebSocketProtocol);
+ } catch (err) {
+ const message = 'Invalid Sec-WebSocket-Protocol header';
+ abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
+ return;
+ }
+ }
+
+ const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
+ const extensions = {};
+
+ if (
+ this.options.perMessageDeflate &&
+ secWebSocketExtensions !== undefined
+ ) {
+ const perMessageDeflate = new PerMessageDeflate({
+ ...this.options.perMessageDeflate,
+ isServer: true,
+ maxPayload: this.options.maxPayload
+ });
+
+ try {
+ const offers = extension.parse(secWebSocketExtensions);
+
+ if (offers[PerMessageDeflate.extensionName]) {
+ perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
+ extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
+ }
+ } catch (err) {
+ const message =
+ 'Invalid or unacceptable Sec-WebSocket-Extensions header';
+ abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
+ return;
+ }
+ }
+
+ //
+ // Optionally call external client verification handler.
+ //
+ if (this.options.verifyClient) {
+ const info = {
+ origin:
+ req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
+ secure: !!(req.socket.authorized || req.socket.encrypted),
+ req
+ };
+
+ if (this.options.verifyClient.length === 2) {
+ this.options.verifyClient(info, (verified, code, message, headers) => {
+ if (!verified) {
+ return abortHandshake(socket, code || 401, message, headers);
+ }
+
+ this.completeUpgrade(
+ extensions,
+ key,
+ protocols,
+ req,
+ socket,
+ head,
+ cb
+ );
+ });
+ return;
+ }
+
+ if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
+ }
+
+ this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
+ }
+
+ /**
+ * Upgrade the connection to WebSocket.
+ *
+ * @param {Object} extensions The accepted extensions
+ * @param {String} key The value of the `Sec-WebSocket-Key` header
+ * @param {Set} protocols The subprotocols
+ * @param {http.IncomingMessage} req The request object
+ * @param {Duplex} socket The network socket between the server and client
+ * @param {Buffer} head The first packet of the upgraded stream
+ * @param {Function} cb Callback
+ * @throws {Error} If called more than once with the same socket
+ * @private
+ */
+ completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
+ //
+ // Destroy the socket if the client has already sent a FIN packet.
+ //
+ if (!socket.readable || !socket.writable) return socket.destroy();
+
+ if (socket[kWebSocket]) {
+ throw new Error(
+ 'server.handleUpgrade() was called more than once with the same ' +
+ 'socket, possibly due to a misconfiguration'
+ );
+ }
+
+ if (this._state > RUNNING) return abortHandshake(socket, 503);
+
+ const digest = createHash('sha1')
+ .update(key + GUID)
+ .digest('base64');
+
+ const headers = [
+ 'HTTP/1.1 101 Switching Protocols',
+ 'Upgrade: websocket',
+ 'Connection: Upgrade',
+ `Sec-WebSocket-Accept: ${digest}`
+ ];
+
+ const ws = new this.options.WebSocket(null, undefined, this.options);
+
+ if (protocols.size) {
+ //
+ // Optionally call external protocol selection handler.
+ //
+ const protocol = this.options.handleProtocols
+ ? this.options.handleProtocols(protocols, req)
+ : protocols.values().next().value;
+
+ if (protocol) {
+ headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
+ ws._protocol = protocol;
+ }
+ }
+
+ if (extensions[PerMessageDeflate.extensionName]) {
+ const params = extensions[PerMessageDeflate.extensionName].params;
+ const value = extension.format({
+ [PerMessageDeflate.extensionName]: [params]
+ });
+ headers.push(`Sec-WebSocket-Extensions: ${value}`);
+ ws._extensions = extensions;
+ }
+
+ //
+ // Allow external modification/inspection of handshake headers.
+ //
+ this.emit('headers', headers, req);
+
+ socket.write(headers.concat('\r\n').join('\r\n'));
+ socket.removeListener('error', socketOnError);
+
+ ws.setSocket(socket, head, {
+ allowSynchronousEvents: this.options.allowSynchronousEvents,
+ maxPayload: this.options.maxPayload,
+ skipUTF8Validation: this.options.skipUTF8Validation
+ });
+
+ if (this.clients) {
+ this.clients.add(ws);
+ ws.on('close', () => {
+ this.clients.delete(ws);
+
+ if (this._shouldEmitClose && !this.clients.size) {
+ process.nextTick(emitClose, this);
+ }
+ });
+ }
+
+ cb(ws, req);
+ }
+}
+
+module.exports = WebSocketServer;
+
+/**
+ * Add event listeners on an `EventEmitter` using a map of
+ * pairs.
+ *
+ * @param {EventEmitter} server The event emitter
+ * @param {Object.} map The listeners to add
+ * @return {Function} A function that will remove the added listeners when
+ * called
+ * @private
+ */
+function addListeners(server, map) {
+ for (const event of Object.keys(map)) server.on(event, map[event]);
+
+ return function removeListeners() {
+ for (const event of Object.keys(map)) {
+ server.removeListener(event, map[event]);
+ }
+ };
+}
+
+/**
+ * Emit a `'close'` event on an `EventEmitter`.
+ *
+ * @param {EventEmitter} server The event emitter
+ * @private
+ */
+function emitClose(server) {
+ server._state = CLOSED;
+ server.emit('close');
+}
+
+/**
+ * Handle socket errors.
+ *
+ * @private
+ */
+function socketOnError() {
+ this.destroy();
+}
+
+/**
+ * Close the connection when preconditions are not fulfilled.
+ *
+ * @param {Duplex} socket The socket of the upgrade request
+ * @param {Number} code The HTTP response status code
+ * @param {String} [message] The HTTP response body
+ * @param {Object} [headers] Additional HTTP response headers
+ * @private
+ */
+function abortHandshake(socket, code, message, headers) {
+ //
+ // The socket is writable unless the user destroyed or ended it before calling
+ // `server.handleUpgrade()` or in the `verifyClient` function, which is a user
+ // error. Handling this does not make much sense as the worst that can happen
+ // is that some of the data written by the user might be discarded due to the
+ // call to `socket.end()` below, which triggers an `'error'` event that in
+ // turn causes the socket to be destroyed.
+ //
+ message = message || http.STATUS_CODES[code];
+ headers = {
+ Connection: 'close',
+ 'Content-Type': 'text/html',
+ 'Content-Length': Buffer.byteLength(message),
+ ...headers
+ };
+
+ socket.once('finish', socket.destroy);
+
+ socket.end(
+ `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
+ Object.keys(headers)
+ .map((h) => `${h}: ${headers[h]}`)
+ .join('\r\n') +
+ '\r\n\r\n' +
+ message
+ );
+}
+
+/**
+ * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
+ * one listener for it, otherwise call `abortHandshake()`.
+ *
+ * @param {WebSocketServer} server The WebSocket server
+ * @param {http.IncomingMessage} req The request object
+ * @param {Duplex} socket The socket of the upgrade request
+ * @param {Number} code The HTTP response status code
+ * @param {String} message The HTTP response body
+ * @param {Object} [headers] The HTTP response headers
+ * @private
+ */
+function abortHandshakeOrEmitwsClientError(
+ server,
+ req,
+ socket,
+ code,
+ message,
+ headers
+) {
+ if (server.listenerCount('wsClientError')) {
+ const err = new Error(message);
+ Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
+
+ server.emit('wsClientError', err, socket, req);
+ } else {
+ abortHandshake(socket, code, message, headers);
+ }
+}
diff --git a/node_modules/ws/lib/websocket.js b/node_modules/ws/lib/websocket.js
new file mode 100644
index 0000000000..75d5bb2831
--- /dev/null
+++ b/node_modules/ws/lib/websocket.js
@@ -0,0 +1,1393 @@
+/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */
+
+'use strict';
+
+const EventEmitter = require('events');
+const https = require('https');
+const http = require('http');
+const net = require('net');
+const tls = require('tls');
+const { randomBytes, createHash } = require('crypto');
+const { Duplex, Readable } = require('stream');
+const { URL } = require('url');
+
+const PerMessageDeflate = require('./permessage-deflate');
+const Receiver = require('./receiver');
+const Sender = require('./sender');
+const { isBlob } = require('./validation');
+
+const {
+ BINARY_TYPES,
+ CLOSE_TIMEOUT,
+ EMPTY_BUFFER,
+ GUID,
+ kForOnEventAttribute,
+ kListener,
+ kStatusCode,
+ kWebSocket,
+ NOOP
+} = require('./constants');
+const {
+ EventTarget: { addEventListener, removeEventListener }
+} = require('./event-target');
+const { format, parse } = require('./extension');
+const { toBuffer } = require('./buffer-util');
+
+const kAborted = Symbol('kAborted');
+const protocolVersions = [8, 13];
+const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
+const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/;
+
+/**
+ * Class representing a WebSocket.
+ *
+ * @extends EventEmitter
+ */
+class WebSocket extends EventEmitter {
+ /**
+ * Create a new `WebSocket`.
+ *
+ * @param {(String|URL)} address The URL to which to connect
+ * @param {(String|String[])} [protocols] The subprotocols
+ * @param {Object} [options] Connection options
+ */
+ constructor(address, protocols, options) {
+ super();
+
+ this._binaryType = BINARY_TYPES[0];
+ this._closeCode = 1006;
+ this._closeFrameReceived = false;
+ this._closeFrameSent = false;
+ this._closeMessage = EMPTY_BUFFER;
+ this._closeTimer = null;
+ this._errorEmitted = false;
+ this._extensions = {};
+ this._paused = false;
+ this._protocol = '';
+ this._readyState = WebSocket.CONNECTING;
+ this._receiver = null;
+ this._sender = null;
+ this._socket = null;
+
+ if (address !== null) {
+ this._bufferedAmount = 0;
+ this._isServer = false;
+ this._redirects = 0;
+
+ if (protocols === undefined) {
+ protocols = [];
+ } else if (!Array.isArray(protocols)) {
+ if (typeof protocols === 'object' && protocols !== null) {
+ options = protocols;
+ protocols = [];
+ } else {
+ protocols = [protocols];
+ }
+ }
+
+ initAsClient(this, address, protocols, options);
+ } else {
+ this._autoPong = options.autoPong;
+ this._closeTimeout = options.closeTimeout;
+ this._isServer = true;
+ }
+ }
+
+ /**
+ * For historical reasons, the custom "nodebuffer" type is used by the default
+ * instead of "blob".
+ *
+ * @type {String}
+ */
+ get binaryType() {
+ return this._binaryType;
+ }
+
+ set binaryType(type) {
+ if (!BINARY_TYPES.includes(type)) return;
+
+ this._binaryType = type;
+
+ //
+ // Allow to change `binaryType` on the fly.
+ //
+ if (this._receiver) this._receiver._binaryType = type;
+ }
+
+ /**
+ * @type {Number}
+ */
+ get bufferedAmount() {
+ if (!this._socket) return this._bufferedAmount;
+
+ return this._socket._writableState.length + this._sender._bufferedBytes;
+ }
+
+ /**
+ * @type {String}
+ */
+ get extensions() {
+ return Object.keys(this._extensions).join();
+ }
+
+ /**
+ * @type {Boolean}
+ */
+ get isPaused() {
+ return this._paused;
+ }
+
+ /**
+ * @type {Function}
+ */
+ /* istanbul ignore next */
+ get onclose() {
+ return null;
+ }
+
+ /**
+ * @type {Function}
+ */
+ /* istanbul ignore next */
+ get onerror() {
+ return null;
+ }
+
+ /**
+ * @type {Function}
+ */
+ /* istanbul ignore next */
+ get onopen() {
+ return null;
+ }
+
+ /**
+ * @type {Function}
+ */
+ /* istanbul ignore next */
+ get onmessage() {
+ return null;
+ }
+
+ /**
+ * @type {String}
+ */
+ get protocol() {
+ return this._protocol;
+ }
+
+ /**
+ * @type {Number}
+ */
+ get readyState() {
+ return this._readyState;
+ }
+
+ /**
+ * @type {String}
+ */
+ get url() {
+ return this._url;
+ }
+
+ /**
+ * Set up the socket and the internal resources.
+ *
+ * @param {Duplex} socket The network socket between the server and client
+ * @param {Buffer} head The first packet of the upgraded stream
+ * @param {Object} options Options object
+ * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether
+ * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
+ * multiple times in the same tick
+ * @param {Function} [options.generateMask] The function used to generate the
+ * masking key
+ * @param {Number} [options.maxPayload=0] The maximum allowed message size
+ * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
+ * not to skip UTF-8 validation for text and close messages
+ * @private
+ */
+ setSocket(socket, head, options) {
+ const receiver = new Receiver({
+ allowSynchronousEvents: options.allowSynchronousEvents,
+ binaryType: this.binaryType,
+ extensions: this._extensions,
+ isServer: this._isServer,
+ maxPayload: options.maxPayload,
+ skipUTF8Validation: options.skipUTF8Validation
+ });
+
+ const sender = new Sender(socket, this._extensions, options.generateMask);
+
+ this._receiver = receiver;
+ this._sender = sender;
+ this._socket = socket;
+
+ receiver[kWebSocket] = this;
+ sender[kWebSocket] = this;
+ socket[kWebSocket] = this;
+
+ receiver.on('conclude', receiverOnConclude);
+ receiver.on('drain', receiverOnDrain);
+ receiver.on('error', receiverOnError);
+ receiver.on('message', receiverOnMessage);
+ receiver.on('ping', receiverOnPing);
+ receiver.on('pong', receiverOnPong);
+
+ sender.onerror = senderOnError;
+
+ //
+ // These methods may not be available if `socket` is just a `Duplex`.
+ //
+ if (socket.setTimeout) socket.setTimeout(0);
+ if (socket.setNoDelay) socket.setNoDelay();
+
+ if (head.length > 0) socket.unshift(head);
+
+ socket.on('close', socketOnClose);
+ socket.on('data', socketOnData);
+ socket.on('end', socketOnEnd);
+ socket.on('error', socketOnError);
+
+ this._readyState = WebSocket.OPEN;
+ this.emit('open');
+ }
+
+ /**
+ * Emit the `'close'` event.
+ *
+ * @private
+ */
+ emitClose() {
+ if (!this._socket) {
+ this._readyState = WebSocket.CLOSED;
+ this.emit('close', this._closeCode, this._closeMessage);
+ return;
+ }
+
+ if (this._extensions[PerMessageDeflate.extensionName]) {
+ this._extensions[PerMessageDeflate.extensionName].cleanup();
+ }
+
+ this._receiver.removeAllListeners();
+ this._readyState = WebSocket.CLOSED;
+ this.emit('close', this._closeCode, this._closeMessage);
+ }
+
+ /**
+ * Start a closing handshake.
+ *
+ * +----------+ +-----------+ +----------+
+ * - - -|ws.close()|-->|close frame|-->|ws.close()|- - -
+ * | +----------+ +-----------+ +----------+ |
+ * +----------+ +-----------+ |
+ * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING
+ * +----------+ +-----------+ |
+ * | | | +---+ |
+ * +------------------------+-->|fin| - - - -
+ * | +---+ | +---+
+ * - - - - -|fin|<---------------------+
+ * +---+
+ *
+ * @param {Number} [code] Status code explaining why the connection is closing
+ * @param {(String|Buffer)} [data] The reason why the connection is
+ * closing
+ * @public
+ */
+ close(code, data) {
+ if (this.readyState === WebSocket.CLOSED) return;
+ if (this.readyState === WebSocket.CONNECTING) {
+ const msg = 'WebSocket was closed before the connection was established';
+ abortHandshake(this, this._req, msg);
+ return;
+ }
+
+ if (this.readyState === WebSocket.CLOSING) {
+ if (
+ this._closeFrameSent &&
+ (this._closeFrameReceived || this._receiver._writableState.errorEmitted)
+ ) {
+ this._socket.end();
+ }
+
+ return;
+ }
+
+ this._readyState = WebSocket.CLOSING;
+ this._sender.close(code, data, !this._isServer, (err) => {
+ //
+ // This error is handled by the `'error'` listener on the socket. We only
+ // want to know if the close frame has been sent here.
+ //
+ if (err) return;
+
+ this._closeFrameSent = true;
+
+ if (
+ this._closeFrameReceived ||
+ this._receiver._writableState.errorEmitted
+ ) {
+ this._socket.end();
+ }
+ });
+
+ setCloseTimer(this);
+ }
+
+ /**
+ * Pause the socket.
+ *
+ * @public
+ */
+ pause() {
+ if (
+ this.readyState === WebSocket.CONNECTING ||
+ this.readyState === WebSocket.CLOSED
+ ) {
+ return;
+ }
+
+ this._paused = true;
+ this._socket.pause();
+ }
+
+ /**
+ * Send a ping.
+ *
+ * @param {*} [data] The data to send
+ * @param {Boolean} [mask] Indicates whether or not to mask `data`
+ * @param {Function} [cb] Callback which is executed when the ping is sent
+ * @public
+ */
+ ping(data, mask, cb) {
+ if (this.readyState === WebSocket.CONNECTING) {
+ throw new Error('WebSocket is not open: readyState 0 (CONNECTING)');
+ }
+
+ if (typeof data === 'function') {
+ cb = data;
+ data = mask = undefined;
+ } else if (typeof mask === 'function') {
+ cb = mask;
+ mask = undefined;
+ }
+
+ if (typeof data === 'number') data = data.toString();
+
+ if (this.readyState !== WebSocket.OPEN) {
+ sendAfterClose(this, data, cb);
+ return;
+ }
+
+ if (mask === undefined) mask = !this._isServer;
+ this._sender.ping(data || EMPTY_BUFFER, mask, cb);
+ }
+
+ /**
+ * Send a pong.
+ *
+ * @param {*} [data] The data to send
+ * @param {Boolean} [mask] Indicates whether or not to mask `data`
+ * @param {Function} [cb] Callback which is executed when the pong is sent
+ * @public
+ */
+ pong(data, mask, cb) {
+ if (this.readyState === WebSocket.CONNECTING) {
+ throw new Error('WebSocket is not open: readyState 0 (CONNECTING)');
+ }
+
+ if (typeof data === 'function') {
+ cb = data;
+ data = mask = undefined;
+ } else if (typeof mask === 'function') {
+ cb = mask;
+ mask = undefined;
+ }
+
+ if (typeof data === 'number') data = data.toString();
+
+ if (this.readyState !== WebSocket.OPEN) {
+ sendAfterClose(this, data, cb);
+ return;
+ }
+
+ if (mask === undefined) mask = !this._isServer;
+ this._sender.pong(data || EMPTY_BUFFER, mask, cb);
+ }
+
+ /**
+ * Resume the socket.
+ *
+ * @public
+ */
+ resume() {
+ if (
+ this.readyState === WebSocket.CONNECTING ||
+ this.readyState === WebSocket.CLOSED
+ ) {
+ return;
+ }
+
+ this._paused = false;
+ if (!this._receiver._writableState.needDrain) this._socket.resume();
+ }
+
+ /**
+ * Send a data message.
+ *
+ * @param {*} data The message to send
+ * @param {Object} [options] Options object
+ * @param {Boolean} [options.binary] Specifies whether `data` is binary or
+ * text
+ * @param {Boolean} [options.compress] Specifies whether or not to compress
+ * `data`
+ * @param {Boolean} [options.fin=true] Specifies whether the fragment is the
+ * last one
+ * @param {Boolean} [options.mask] Specifies whether or not to mask `data`
+ * @param {Function} [cb] Callback which is executed when data is written out
+ * @public
+ */
+ send(data, options, cb) {
+ if (this.readyState === WebSocket.CONNECTING) {
+ throw new Error('WebSocket is not open: readyState 0 (CONNECTING)');
+ }
+
+ if (typeof options === 'function') {
+ cb = options;
+ options = {};
+ }
+
+ if (typeof data === 'number') data = data.toString();
+
+ if (this.readyState !== WebSocket.OPEN) {
+ sendAfterClose(this, data, cb);
+ return;
+ }
+
+ const opts = {
+ binary: typeof data !== 'string',
+ mask: !this._isServer,
+ compress: true,
+ fin: true,
+ ...options
+ };
+
+ if (!this._extensions[PerMessageDeflate.extensionName]) {
+ opts.compress = false;
+ }
+
+ this._sender.send(data || EMPTY_BUFFER, opts, cb);
+ }
+
+ /**
+ * Forcibly close the connection.
+ *
+ * @public
+ */
+ terminate() {
+ if (this.readyState === WebSocket.CLOSED) return;
+ if (this.readyState === WebSocket.CONNECTING) {
+ const msg = 'WebSocket was closed before the connection was established';
+ abortHandshake(this, this._req, msg);
+ return;
+ }
+
+ if (this._socket) {
+ this._readyState = WebSocket.CLOSING;
+ this._socket.destroy();
+ }
+ }
+}
+
+/**
+ * @constant {Number} CONNECTING
+ * @memberof WebSocket
+ */
+Object.defineProperty(WebSocket, 'CONNECTING', {
+ enumerable: true,
+ value: readyStates.indexOf('CONNECTING')
+});
+
+/**
+ * @constant {Number} CONNECTING
+ * @memberof WebSocket.prototype
+ */
+Object.defineProperty(WebSocket.prototype, 'CONNECTING', {
+ enumerable: true,
+ value: readyStates.indexOf('CONNECTING')
+});
+
+/**
+ * @constant {Number} OPEN
+ * @memberof WebSocket
+ */
+Object.defineProperty(WebSocket, 'OPEN', {
+ enumerable: true,
+ value: readyStates.indexOf('OPEN')
+});
+
+/**
+ * @constant {Number} OPEN
+ * @memberof WebSocket.prototype
+ */
+Object.defineProperty(WebSocket.prototype, 'OPEN', {
+ enumerable: true,
+ value: readyStates.indexOf('OPEN')
+});
+
+/**
+ * @constant {Number} CLOSING
+ * @memberof WebSocket
+ */
+Object.defineProperty(WebSocket, 'CLOSING', {
+ enumerable: true,
+ value: readyStates.indexOf('CLOSING')
+});
+
+/**
+ * @constant {Number} CLOSING
+ * @memberof WebSocket.prototype
+ */
+Object.defineProperty(WebSocket.prototype, 'CLOSING', {
+ enumerable: true,
+ value: readyStates.indexOf('CLOSING')
+});
+
+/**
+ * @constant {Number} CLOSED
+ * @memberof WebSocket
+ */
+Object.defineProperty(WebSocket, 'CLOSED', {
+ enumerable: true,
+ value: readyStates.indexOf('CLOSED')
+});
+
+/**
+ * @constant {Number} CLOSED
+ * @memberof WebSocket.prototype
+ */
+Object.defineProperty(WebSocket.prototype, 'CLOSED', {
+ enumerable: true,
+ value: readyStates.indexOf('CLOSED')
+});
+
+[
+ 'binaryType',
+ 'bufferedAmount',
+ 'extensions',
+ 'isPaused',
+ 'protocol',
+ 'readyState',
+ 'url'
+].forEach((property) => {
+ Object.defineProperty(WebSocket.prototype, property, { enumerable: true });
+});
+
+//
+// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes.
+// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface
+//
+['open', 'error', 'close', 'message'].forEach((method) => {
+ Object.defineProperty(WebSocket.prototype, `on${method}`, {
+ enumerable: true,
+ get() {
+ for (const listener of this.listeners(method)) {
+ if (listener[kForOnEventAttribute]) return listener[kListener];
+ }
+
+ return null;
+ },
+ set(handler) {
+ for (const listener of this.listeners(method)) {
+ if (listener[kForOnEventAttribute]) {
+ this.removeListener(method, listener);
+ break;
+ }
+ }
+
+ if (typeof handler !== 'function') return;
+
+ this.addEventListener(method, handler, {
+ [kForOnEventAttribute]: true
+ });
+ }
+ });
+});
+
+WebSocket.prototype.addEventListener = addEventListener;
+WebSocket.prototype.removeEventListener = removeEventListener;
+
+module.exports = WebSocket;
+
+/**
+ * Initialize a WebSocket client.
+ *
+ * @param {WebSocket} websocket The client to initialize
+ * @param {(String|URL)} address The URL to which to connect
+ * @param {Array} protocols The subprotocols
+ * @param {Object} [options] Connection options
+ * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether any
+ * of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple
+ * times in the same tick
+ * @param {Boolean} [options.autoPong=true] Specifies whether or not to
+ * automatically send a pong in response to a ping
+ * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to wait
+ * for the closing handshake to finish after `websocket.close()` is called
+ * @param {Function} [options.finishRequest] A function which can be used to
+ * customize the headers of each http request before it is sent
+ * @param {Boolean} [options.followRedirects=false] Whether or not to follow
+ * redirects
+ * @param {Function} [options.generateMask] The function used to generate the
+ * masking key
+ * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the
+ * handshake request
+ * @param {Number} [options.maxPayload=104857600] The maximum allowed message
+ * size
+ * @param {Number} [options.maxRedirects=10] The maximum number of redirects
+ * allowed
+ * @param {String} [options.origin] Value of the `Origin` or
+ * `Sec-WebSocket-Origin` header
+ * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable
+ * permessage-deflate
+ * @param {Number} [options.protocolVersion=13] Value of the
+ * `Sec-WebSocket-Version` header
+ * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
+ * not to skip UTF-8 validation for text and close messages
+ * @private
+ */
+function initAsClient(websocket, address, protocols, options) {
+ const opts = {
+ allowSynchronousEvents: true,
+ autoPong: true,
+ closeTimeout: CLOSE_TIMEOUT,
+ protocolVersion: protocolVersions[1],
+ maxPayload: 100 * 1024 * 1024,
+ skipUTF8Validation: false,
+ perMessageDeflate: true,
+ followRedirects: false,
+ maxRedirects: 10,
+ ...options,
+ socketPath: undefined,
+ hostname: undefined,
+ protocol: undefined,
+ timeout: undefined,
+ method: 'GET',
+ host: undefined,
+ path: undefined,
+ port: undefined
+ };
+
+ websocket._autoPong = opts.autoPong;
+ websocket._closeTimeout = opts.closeTimeout;
+
+ if (!protocolVersions.includes(opts.protocolVersion)) {
+ throw new RangeError(
+ `Unsupported protocol version: ${opts.protocolVersion} ` +
+ `(supported versions: ${protocolVersions.join(', ')})`
+ );
+ }
+
+ let parsedUrl;
+
+ if (address instanceof URL) {
+ parsedUrl = address;
+ } else {
+ try {
+ parsedUrl = new URL(address);
+ } catch {
+ throw new SyntaxError(`Invalid URL: ${address}`);
+ }
+ }
+
+ if (parsedUrl.protocol === 'http:') {
+ parsedUrl.protocol = 'ws:';
+ } else if (parsedUrl.protocol === 'https:') {
+ parsedUrl.protocol = 'wss:';
+ }
+
+ websocket._url = parsedUrl.href;
+
+ const isSecure = parsedUrl.protocol === 'wss:';
+ const isIpcUrl = parsedUrl.protocol === 'ws+unix:';
+ let invalidUrlMessage;
+
+ if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) {
+ invalidUrlMessage =
+ 'The URL\'s protocol must be one of "ws:", "wss:", ' +
+ '"http:", "https:", or "ws+unix:"';
+ } else if (isIpcUrl && !parsedUrl.pathname) {
+ invalidUrlMessage = "The URL's pathname is empty";
+ } else if (parsedUrl.hash) {
+ invalidUrlMessage = 'The URL contains a fragment identifier';
+ }
+
+ if (invalidUrlMessage) {
+ const err = new SyntaxError(invalidUrlMessage);
+
+ if (websocket._redirects === 0) {
+ throw err;
+ } else {
+ emitErrorAndClose(websocket, err);
+ return;
+ }
+ }
+
+ const defaultPort = isSecure ? 443 : 80;
+ const key = randomBytes(16).toString('base64');
+ const request = isSecure ? https.request : http.request;
+ const protocolSet = new Set();
+ let perMessageDeflate;
+
+ opts.createConnection =
+ opts.createConnection || (isSecure ? tlsConnect : netConnect);
+ opts.defaultPort = opts.defaultPort || defaultPort;
+ opts.port = parsedUrl.port || defaultPort;
+ opts.host = parsedUrl.hostname.startsWith('[')
+ ? parsedUrl.hostname.slice(1, -1)
+ : parsedUrl.hostname;
+ opts.headers = {
+ ...opts.headers,
+ 'Sec-WebSocket-Version': opts.protocolVersion,
+ 'Sec-WebSocket-Key': key,
+ Connection: 'Upgrade',
+ Upgrade: 'websocket'
+ };
+ opts.path = parsedUrl.pathname + parsedUrl.search;
+ opts.timeout = opts.handshakeTimeout;
+
+ if (opts.perMessageDeflate) {
+ perMessageDeflate = new PerMessageDeflate({
+ ...opts.perMessageDeflate,
+ isServer: false,
+ maxPayload: opts.maxPayload
+ });
+ opts.headers['Sec-WebSocket-Extensions'] = format({
+ [PerMessageDeflate.extensionName]: perMessageDeflate.offer()
+ });
+ }
+ if (protocols.length) {
+ for (const protocol of protocols) {
+ if (
+ typeof protocol !== 'string' ||
+ !subprotocolRegex.test(protocol) ||
+ protocolSet.has(protocol)
+ ) {
+ throw new SyntaxError(
+ 'An invalid or duplicated subprotocol was specified'
+ );
+ }
+
+ protocolSet.add(protocol);
+ }
+
+ opts.headers['Sec-WebSocket-Protocol'] = protocols.join(',');
+ }
+ if (opts.origin) {
+ if (opts.protocolVersion < 13) {
+ opts.headers['Sec-WebSocket-Origin'] = opts.origin;
+ } else {
+ opts.headers.Origin = opts.origin;
+ }
+ }
+ if (parsedUrl.username || parsedUrl.password) {
+ opts.auth = `${parsedUrl.username}:${parsedUrl.password}`;
+ }
+
+ if (isIpcUrl) {
+ const parts = opts.path.split(':');
+
+ opts.socketPath = parts[0];
+ opts.path = parts[1];
+ }
+
+ let req;
+
+ if (opts.followRedirects) {
+ if (websocket._redirects === 0) {
+ websocket._originalIpc = isIpcUrl;
+ websocket._originalSecure = isSecure;
+ websocket._originalHostOrSocketPath = isIpcUrl
+ ? opts.socketPath
+ : parsedUrl.host;
+
+ const headers = options && options.headers;
+
+ //
+ // Shallow copy the user provided options so that headers can be changed
+ // without mutating the original object.
+ //
+ options = { ...options, headers: {} };
+
+ if (headers) {
+ for (const [key, value] of Object.entries(headers)) {
+ options.headers[key.toLowerCase()] = value;
+ }
+ }
+ } else if (websocket.listenerCount('redirect') === 0) {
+ const isSameHost = isIpcUrl
+ ? websocket._originalIpc
+ ? opts.socketPath === websocket._originalHostOrSocketPath
+ : false
+ : websocket._originalIpc
+ ? false
+ : parsedUrl.host === websocket._originalHostOrSocketPath;
+
+ if (!isSameHost || (websocket._originalSecure && !isSecure)) {
+ //
+ // Match curl 7.77.0 behavior and drop the following headers. These
+ // headers are also dropped when following a redirect to a subdomain.
+ //
+ delete opts.headers.authorization;
+ delete opts.headers.cookie;
+
+ if (!isSameHost) delete opts.headers.host;
+
+ opts.auth = undefined;
+ }
+ }
+
+ //
+ // Match curl 7.77.0 behavior and make the first `Authorization` header win.
+ // If the `Authorization` header is set, then there is nothing to do as it
+ // will take precedence.
+ //
+ if (opts.auth && !options.headers.authorization) {
+ options.headers.authorization =
+ 'Basic ' + Buffer.from(opts.auth).toString('base64');
+ }
+
+ req = websocket._req = request(opts);
+
+ if (websocket._redirects) {
+ //
+ // Unlike what is done for the `'upgrade'` event, no early exit is
+ // triggered here if the user calls `websocket.close()` or
+ // `websocket.terminate()` from a listener of the `'redirect'` event. This
+ // is because the user can also call `request.destroy()` with an error
+ // before calling `websocket.close()` or `websocket.terminate()` and this
+ // would result in an error being emitted on the `request` object with no
+ // `'error'` event listeners attached.
+ //
+ websocket.emit('redirect', websocket.url, req);
+ }
+ } else {
+ req = websocket._req = request(opts);
+ }
+
+ if (opts.timeout) {
+ req.on('timeout', () => {
+ abortHandshake(websocket, req, 'Opening handshake has timed out');
+ });
+ }
+
+ req.on('error', (err) => {
+ if (req === null || req[kAborted]) return;
+
+ req = websocket._req = null;
+ emitErrorAndClose(websocket, err);
+ });
+
+ req.on('response', (res) => {
+ const location = res.headers.location;
+ const statusCode = res.statusCode;
+
+ if (
+ location &&
+ opts.followRedirects &&
+ statusCode >= 300 &&
+ statusCode < 400
+ ) {
+ if (++websocket._redirects > opts.maxRedirects) {
+ abortHandshake(websocket, req, 'Maximum redirects exceeded');
+ return;
+ }
+
+ req.abort();
+
+ let addr;
+
+ try {
+ addr = new URL(location, address);
+ } catch (e) {
+ const err = new SyntaxError(`Invalid URL: ${location}`);
+ emitErrorAndClose(websocket, err);
+ return;
+ }
+
+ initAsClient(websocket, addr, protocols, options);
+ } else if (!websocket.emit('unexpected-response', req, res)) {
+ abortHandshake(
+ websocket,
+ req,
+ `Unexpected server response: ${res.statusCode}`
+ );
+ }
+ });
+
+ req.on('upgrade', (res, socket, head) => {
+ websocket.emit('upgrade', res);
+
+ //
+ // The user may have closed the connection from a listener of the
+ // `'upgrade'` event.
+ //
+ if (websocket.readyState !== WebSocket.CONNECTING) return;
+
+ req = websocket._req = null;
+
+ const upgrade = res.headers.upgrade;
+
+ if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
+ abortHandshake(websocket, socket, 'Invalid Upgrade header');
+ return;
+ }
+
+ const digest = createHash('sha1')
+ .update(key + GUID)
+ .digest('base64');
+
+ if (res.headers['sec-websocket-accept'] !== digest) {
+ abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header');
+ return;
+ }
+
+ const serverProt = res.headers['sec-websocket-protocol'];
+ let protError;
+
+ if (serverProt !== undefined) {
+ if (!protocolSet.size) {
+ protError = 'Server sent a subprotocol but none was requested';
+ } else if (!protocolSet.has(serverProt)) {
+ protError = 'Server sent an invalid subprotocol';
+ }
+ } else if (protocolSet.size) {
+ protError = 'Server sent no subprotocol';
+ }
+
+ if (protError) {
+ abortHandshake(websocket, socket, protError);
+ return;
+ }
+
+ if (serverProt) websocket._protocol = serverProt;
+
+ const secWebSocketExtensions = res.headers['sec-websocket-extensions'];
+
+ if (secWebSocketExtensions !== undefined) {
+ if (!perMessageDeflate) {
+ const message =
+ 'Server sent a Sec-WebSocket-Extensions header but no extension ' +
+ 'was requested';
+ abortHandshake(websocket, socket, message);
+ return;
+ }
+
+ let extensions;
+
+ try {
+ extensions = parse(secWebSocketExtensions);
+ } catch (err) {
+ const message = 'Invalid Sec-WebSocket-Extensions header';
+ abortHandshake(websocket, socket, message);
+ return;
+ }
+
+ const extensionNames = Object.keys(extensions);
+
+ if (
+ extensionNames.length !== 1 ||
+ extensionNames[0] !== PerMessageDeflate.extensionName
+ ) {
+ const message = 'Server indicated an extension that was not requested';
+ abortHandshake(websocket, socket, message);
+ return;
+ }
+
+ try {
+ perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]);
+ } catch (err) {
+ const message = 'Invalid Sec-WebSocket-Extensions header';
+ abortHandshake(websocket, socket, message);
+ return;
+ }
+
+ websocket._extensions[PerMessageDeflate.extensionName] =
+ perMessageDeflate;
+ }
+
+ websocket.setSocket(socket, head, {
+ allowSynchronousEvents: opts.allowSynchronousEvents,
+ generateMask: opts.generateMask,
+ maxPayload: opts.maxPayload,
+ skipUTF8Validation: opts.skipUTF8Validation
+ });
+ });
+
+ if (opts.finishRequest) {
+ opts.finishRequest(req, websocket);
+ } else {
+ req.end();
+ }
+}
+
+/**
+ * Emit the `'error'` and `'close'` events.
+ *
+ * @param {WebSocket} websocket The WebSocket instance
+ * @param {Error} The error to emit
+ * @private
+ */
+function emitErrorAndClose(websocket, err) {
+ websocket._readyState = WebSocket.CLOSING;
+ //
+ // The following assignment is practically useless and is done only for
+ // consistency.
+ //
+ websocket._errorEmitted = true;
+ websocket.emit('error', err);
+ websocket.emitClose();
+}
+
+/**
+ * Create a `net.Socket` and initiate a connection.
+ *
+ * @param {Object} options Connection options
+ * @return {net.Socket} The newly created socket used to start the connection
+ * @private
+ */
+function netConnect(options) {
+ options.path = options.socketPath;
+ return net.connect(options);
+}
+
+/**
+ * Create a `tls.TLSSocket` and initiate a connection.
+ *
+ * @param {Object} options Connection options
+ * @return {tls.TLSSocket} The newly created socket used to start the connection
+ * @private
+ */
+function tlsConnect(options) {
+ options.path = undefined;
+
+ if (!options.servername && options.servername !== '') {
+ options.servername = net.isIP(options.host) ? '' : options.host;
+ }
+
+ return tls.connect(options);
+}
+
+/**
+ * Abort the handshake and emit an error.
+ *
+ * @param {WebSocket} websocket The WebSocket instance
+ * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to
+ * abort or the socket to destroy
+ * @param {String} message The error message
+ * @private
+ */
+function abortHandshake(websocket, stream, message) {
+ websocket._readyState = WebSocket.CLOSING;
+
+ const err = new Error(message);
+ Error.captureStackTrace(err, abortHandshake);
+
+ if (stream.setHeader) {
+ stream[kAborted] = true;
+ stream.abort();
+
+ if (stream.socket && !stream.socket.destroyed) {
+ //
+ // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if
+ // called after the request completed. See
+ // https://github.com/websockets/ws/issues/1869.
+ //
+ stream.socket.destroy();
+ }
+
+ process.nextTick(emitErrorAndClose, websocket, err);
+ } else {
+ stream.destroy(err);
+ stream.once('error', websocket.emit.bind(websocket, 'error'));
+ stream.once('close', websocket.emitClose.bind(websocket));
+ }
+}
+
+/**
+ * Handle cases where the `ping()`, `pong()`, or `send()` methods are called
+ * when the `readyState` attribute is `CLOSING` or `CLOSED`.
+ *
+ * @param {WebSocket} websocket The WebSocket instance
+ * @param {*} [data] The data to send
+ * @param {Function} [cb] Callback
+ * @private
+ */
+function sendAfterClose(websocket, data, cb) {
+ if (data) {
+ const length = isBlob(data) ? data.size : toBuffer(data).length;
+
+ //
+ // The `_bufferedAmount` property is used only when the peer is a client and
+ // the opening handshake fails. Under these circumstances, in fact, the
+ // `setSocket()` method is not called, so the `_socket` and `_sender`
+ // properties are set to `null`.
+ //
+ if (websocket._socket) websocket._sender._bufferedBytes += length;
+ else websocket._bufferedAmount += length;
+ }
+
+ if (cb) {
+ const err = new Error(
+ `WebSocket is not open: readyState ${websocket.readyState} ` +
+ `(${readyStates[websocket.readyState]})`
+ );
+ process.nextTick(cb, err);
+ }
+}
+
+/**
+ * The listener of the `Receiver` `'conclude'` event.
+ *
+ * @param {Number} code The status code
+ * @param {Buffer} reason The reason for closing
+ * @private
+ */
+function receiverOnConclude(code, reason) {
+ const websocket = this[kWebSocket];
+
+ websocket._closeFrameReceived = true;
+ websocket._closeMessage = reason;
+ websocket._closeCode = code;
+
+ if (websocket._socket[kWebSocket] === undefined) return;
+
+ websocket._socket.removeListener('data', socketOnData);
+ process.nextTick(resume, websocket._socket);
+
+ if (code === 1005) websocket.close();
+ else websocket.close(code, reason);
+}
+
+/**
+ * The listener of the `Receiver` `'drain'` event.
+ *
+ * @private
+ */
+function receiverOnDrain() {
+ const websocket = this[kWebSocket];
+
+ if (!websocket.isPaused) websocket._socket.resume();
+}
+
+/**
+ * The listener of the `Receiver` `'error'` event.
+ *
+ * @param {(RangeError|Error)} err The emitted error
+ * @private
+ */
+function receiverOnError(err) {
+ const websocket = this[kWebSocket];
+
+ if (websocket._socket[kWebSocket] !== undefined) {
+ websocket._socket.removeListener('data', socketOnData);
+
+ //
+ // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See
+ // https://github.com/websockets/ws/issues/1940.
+ //
+ process.nextTick(resume, websocket._socket);
+
+ websocket.close(err[kStatusCode]);
+ }
+
+ if (!websocket._errorEmitted) {
+ websocket._errorEmitted = true;
+ websocket.emit('error', err);
+ }
+}
+
+/**
+ * The listener of the `Receiver` `'finish'` event.
+ *
+ * @private
+ */
+function receiverOnFinish() {
+ this[kWebSocket].emitClose();
+}
+
+/**
+ * The listener of the `Receiver` `'message'` event.
+ *
+ * @param {Buffer|ArrayBuffer|Buffer[])} data The message
+ * @param {Boolean} isBinary Specifies whether the message is binary or not
+ * @private
+ */
+function receiverOnMessage(data, isBinary) {
+ this[kWebSocket].emit('message', data, isBinary);
+}
+
+/**
+ * The listener of the `Receiver` `'ping'` event.
+ *
+ * @param {Buffer} data The data included in the ping frame
+ * @private
+ */
+function receiverOnPing(data) {
+ const websocket = this[kWebSocket];
+
+ if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP);
+ websocket.emit('ping', data);
+}
+
+/**
+ * The listener of the `Receiver` `'pong'` event.
+ *
+ * @param {Buffer} data The data included in the pong frame
+ * @private
+ */
+function receiverOnPong(data) {
+ this[kWebSocket].emit('pong', data);
+}
+
+/**
+ * Resume a readable stream
+ *
+ * @param {Readable} stream The readable stream
+ * @private
+ */
+function resume(stream) {
+ stream.resume();
+}
+
+/**
+ * The `Sender` error event handler.
+ *
+ * @param {Error} The error
+ * @private
+ */
+function senderOnError(err) {
+ const websocket = this[kWebSocket];
+
+ if (websocket.readyState === WebSocket.CLOSED) return;
+ if (websocket.readyState === WebSocket.OPEN) {
+ websocket._readyState = WebSocket.CLOSING;
+ setCloseTimer(websocket);
+ }
+
+ //
+ // `socket.end()` is used instead of `socket.destroy()` to allow the other
+ // peer to finish sending queued data. There is no need to set a timer here
+ // because `CLOSING` means that it is already set or not needed.
+ //
+ this._socket.end();
+
+ if (!websocket._errorEmitted) {
+ websocket._errorEmitted = true;
+ websocket.emit('error', err);
+ }
+}
+
+/**
+ * Set a timer to destroy the underlying raw socket of a WebSocket.
+ *
+ * @param {WebSocket} websocket The WebSocket instance
+ * @private
+ */
+function setCloseTimer(websocket) {
+ websocket._closeTimer = setTimeout(
+ websocket._socket.destroy.bind(websocket._socket),
+ websocket._closeTimeout
+ );
+}
+
+/**
+ * The listener of the socket `'close'` event.
+ *
+ * @private
+ */
+function socketOnClose() {
+ const websocket = this[kWebSocket];
+
+ this.removeListener('close', socketOnClose);
+ this.removeListener('data', socketOnData);
+ this.removeListener('end', socketOnEnd);
+
+ websocket._readyState = WebSocket.CLOSING;
+
+ //
+ // The close frame might not have been received or the `'end'` event emitted,
+ // for example, if the socket was destroyed due to an error. Ensure that the
+ // `receiver` stream is closed after writing any remaining buffered data to
+ // it. If the readable side of the socket is in flowing mode then there is no
+ // buffered data as everything has been already written. If instead, the
+ // socket is paused, any possible buffered data will be read as a single
+ // chunk.
+ //
+ if (
+ !this._readableState.endEmitted &&
+ !websocket._closeFrameReceived &&
+ !websocket._receiver._writableState.errorEmitted &&
+ this._readableState.length !== 0
+ ) {
+ const chunk = this.read(this._readableState.length);
+
+ websocket._receiver.write(chunk);
+ }
+
+ websocket._receiver.end();
+
+ this[kWebSocket] = undefined;
+
+ clearTimeout(websocket._closeTimer);
+
+ if (
+ websocket._receiver._writableState.finished ||
+ websocket._receiver._writableState.errorEmitted
+ ) {
+ websocket.emitClose();
+ } else {
+ websocket._receiver.on('error', receiverOnFinish);
+ websocket._receiver.on('finish', receiverOnFinish);
+ }
+}
+
+/**
+ * The listener of the socket `'data'` event.
+ *
+ * @param {Buffer} chunk A chunk of data
+ * @private
+ */
+function socketOnData(chunk) {
+ if (!this[kWebSocket]._receiver.write(chunk)) {
+ this.pause();
+ }
+}
+
+/**
+ * The listener of the socket `'end'` event.
+ *
+ * @private
+ */
+function socketOnEnd() {
+ const websocket = this[kWebSocket];
+
+ websocket._readyState = WebSocket.CLOSING;
+ websocket._receiver.end();
+ this.end();
+}
+
+/**
+ * The listener of the socket `'error'` event.
+ *
+ * @private
+ */
+function socketOnError() {
+ const websocket = this[kWebSocket];
+
+ this.removeListener('error', socketOnError);
+ this.on('error', NOOP);
+
+ if (websocket) {
+ websocket._readyState = WebSocket.CLOSING;
+ this.destroy();
+ }
+}
diff --git a/node_modules/ws/package.json b/node_modules/ws/package.json
new file mode 100644
index 0000000000..3618050a8c
--- /dev/null
+++ b/node_modules/ws/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "ws",
+ "version": "8.20.0",
+ "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
+ "keywords": [
+ "HyBi",
+ "Push",
+ "RFC-6455",
+ "WebSocket",
+ "WebSockets",
+ "real-time"
+ ],
+ "homepage": "https://github.com/websockets/ws",
+ "bugs": "https://github.com/websockets/ws/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/websockets/ws.git"
+ },
+ "author": "Einar Otto Stangvik (http://2x.io)",
+ "license": "MIT",
+ "main": "index.js",
+ "exports": {
+ ".": {
+ "browser": "./browser.js",
+ "import": "./wrapper.mjs",
+ "require": "./index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "browser": "browser.js",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "files": [
+ "browser.js",
+ "index.js",
+ "lib/*.js",
+ "wrapper.mjs"
+ ],
+ "scripts": {
+ "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
+ "integration": "mocha --throw-deprecation test/*.integration.js",
+ "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "benchmark": "^2.1.4",
+ "bufferutil": "^4.0.1",
+ "eslint": "^10.0.1",
+ "eslint-config-prettier": "^10.0.1",
+ "eslint-plugin-prettier": "^5.0.0",
+ "globals": "^17.0.0",
+ "mocha": "^8.4.0",
+ "nyc": "^15.0.0",
+ "prettier": "^3.0.0",
+ "utf-8-validate": "^6.0.0"
+ }
+}
diff --git a/node_modules/ws/wrapper.mjs b/node_modules/ws/wrapper.mjs
new file mode 100644
index 0000000000..a8ffabbb0e
--- /dev/null
+++ b/node_modules/ws/wrapper.mjs
@@ -0,0 +1,21 @@
+import createWebSocketStream from './lib/stream.js';
+import extension from './lib/extension.js';
+import PerMessageDeflate from './lib/permessage-deflate.js';
+import Receiver from './lib/receiver.js';
+import Sender from './lib/sender.js';
+import subprotocol from './lib/subprotocol.js';
+import WebSocket from './lib/websocket.js';
+import WebSocketServer from './lib/websocket-server.js';
+
+export {
+ createWebSocketStream,
+ extension,
+ PerMessageDeflate,
+ Receiver,
+ Sender,
+ subprotocol,
+ WebSocket,
+ WebSocketServer
+};
+
+export default WebSocket;
diff --git a/src/Config.zig b/src/Config.zig
index e01b33141b..1dc570b425 100644
--- a/src/Config.zig
+++ b/src/Config.zig
@@ -24,6 +24,7 @@ const log = @import("log.zig");
const dump = @import("browser/dump.zig");
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
+const TlsProfile = @import("network/TlsProfile.zig").TlsProfile;
const mcp = @import("mcp.zig");
pub const RunMode = enum {
@@ -149,6 +150,31 @@ pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
};
}
+pub fn tlsProfile(self: *const Config) *const TlsProfile {
+ const name: ?[]const u8 = switch (self.mode) {
+ inline .serve, .fetch, .mcp => |opts| opts.common.tls_profile,
+ else => null,
+ };
+ if (name) |n| {
+ return TlsProfile.fromName(n) orelse &TlsProfile.default;
+ }
+ return &TlsProfile.default;
+}
+
+pub fn screenWidth(self: *const Config) u32 {
+ return switch (self.mode) {
+ inline .serve, .fetch, .mcp => |opts| opts.common.screen_width,
+ else => 1920,
+ };
+}
+
+pub fn screenHeight(self: *const Config) u32 {
+ return switch (self.mode) {
+ inline .serve, .fetch, .mcp => |opts| opts.common.screen_height,
+ else => 1080,
+ };
+}
+
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
@@ -271,15 +297,23 @@ pub const Common = struct {
web_bot_auth_key_file: ?[]const u8 = null,
web_bot_auth_keyid: ?[]const u8 = null,
web_bot_auth_domain: ?[]const u8 = null,
+
+ screen_width: u32 = 1920,
+ screen_height: u32 = 1080,
+
+ tls_profile: ?[]const u8 = null,
};
/// Pre-formatted HTTP headers for reuse across Http and Client.
/// Must be initialized with an allocator that outlives all HTTP connections.
pub const HttpHeaders = struct {
- const user_agent_base: [:0]const u8 = "Lightpanda/1.0";
+ const user_agent_base: [:0]const u8 = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
- user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0")
+ user_agent: [:0]const u8,
user_agent_header: [:0]const u8,
+ sec_ch_ua_header: [:0]const u8,
+ sec_ch_ua_mobile_header: [:0]const u8,
+ sec_ch_ua_platform_header: [:0]const u8,
proxy_bearer_header: ?[:0]const u8,
@@ -293,6 +327,26 @@ pub const HttpHeaders = struct {
const user_agent_header = try std.fmt.allocPrintSentinel(allocator, "User-Agent: {s}", .{user_agent}, 0);
errdefer allocator.free(user_agent_header);
+ // Client Hints headers — must match the UA string
+ const sec_ch_ua_header: [:0]const u8 = try std.fmt.allocPrintSentinel(
+ allocator,
+ "sec-ch-ua: \"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"",
+ .{},
+ 0,
+ );
+ const sec_ch_ua_mobile_header: [:0]const u8 = try std.fmt.allocPrintSentinel(
+ allocator,
+ "sec-ch-ua-mobile: ?0",
+ .{},
+ 0,
+ );
+ const sec_ch_ua_platform_header: [:0]const u8 = try std.fmt.allocPrintSentinel(
+ allocator,
+ "sec-ch-ua-platform: \"Linux\"",
+ .{},
+ 0,
+ );
+
const proxy_bearer_header: ?[:0]const u8 = if (config.proxyBearerToken()) |token|
try std.fmt.allocPrintSentinel(allocator, "Proxy-Authorization: Bearer {s}", .{token}, 0)
else
@@ -301,6 +355,9 @@ pub const HttpHeaders = struct {
return .{
.user_agent = user_agent,
.user_agent_header = user_agent_header,
+ .sec_ch_ua_header = sec_ch_ua_header,
+ .sec_ch_ua_mobile_header = sec_ch_ua_mobile_header,
+ .sec_ch_ua_platform_header = sec_ch_ua_platform_header,
.proxy_bearer_header = proxy_bearer_header,
};
}
@@ -309,6 +366,9 @@ pub const HttpHeaders = struct {
if (self.proxy_bearer_header) |hdr| {
allocator.free(hdr);
}
+ allocator.free(self.sec_ch_ua_header);
+ allocator.free(self.sec_ch_ua_mobile_header);
+ allocator.free(self.sec_ch_ua_platform_header);
allocator.free(self.user_agent_header);
if (self.user_agent.ptr != user_agent_base.ptr) {
allocator.free(self.user_agent);
@@ -1047,5 +1107,42 @@ fn parseCommonArg(
return true;
}
+ if (std.mem.eql(u8, "--tls-profile", opt) or std.mem.eql(u8, "--tls_profile", opt)) {
+ const str = args.next() orelse {
+ log.fatal(.app, "missing argument value", .{ .arg = opt });
+ return error.InvalidArgument;
+ };
+ if (TlsProfile.fromName(str) == null) {
+ log.fatal(.app, "unknown TLS profile", .{ .arg = opt });
+ return error.InvalidArgument;
+ }
+ common.tls_profile = try allocator.dupe(u8, str);
+ return true;
+ }
+
+ if (std.mem.eql(u8, "--screen-width", opt) or std.mem.eql(u8, "--screen_width", opt)) {
+ const str = args.next() orelse {
+ log.fatal(.app, "missing argument value", .{ .arg = opt });
+ return error.InvalidArgument;
+ };
+ common.screen_width = std.fmt.parseInt(u32, str, 10) catch {
+ log.fatal(.app, "invalid integer value", .{ .arg = opt });
+ return error.InvalidArgument;
+ };
+ return true;
+ }
+
+ if (std.mem.eql(u8, "--screen-height", opt) or std.mem.eql(u8, "--screen_height", opt)) {
+ const str = args.next() orelse {
+ log.fatal(.app, "missing argument value", .{ .arg = opt });
+ return error.InvalidArgument;
+ };
+ common.screen_height = std.fmt.parseInt(u32, str, 10) catch {
+ log.fatal(.app, "invalid integer value", .{ .arg = opt });
+ return error.InvalidArgument;
+ };
+ return true;
+ }
+
return false;
}
diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig
index e183bc6ae4..97f1de0145 100644
--- a/src/browser/HttpClient.zig
+++ b/src/browser/HttpClient.zig
@@ -202,7 +202,7 @@ pub fn changeProxy(self: *Client, proxy: ?[:0]const u8) !void {
}
pub fn newHeaders(self: *const Client) !http.Headers {
- return http.Headers.init(self.network.config.http_headers.user_agent_header);
+ return http.Headers.initWithClientHints(&self.network.config.http_headers);
}
pub fn abort(self: *Client) void {
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index 3bf8962b2c..f6fc9c5c2f 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -1070,8 +1070,23 @@ pub fn isGoingAway(self: *const Page) bool {
return parent.isGoingAway();
}
+/// Find the page that owns a given node's document. If the node belongs to
+/// a child iframe's document, return that iframe's page. Returns null if not found.
+fn resolvePageForNode(self: *Page, node: *Node) ?*Page {
+ const owner_doc = node.ownerDocument(self) orelse return null;
+ if (owner_doc == self.document) return self;
+ for (self.frames.items) |frame_page| {
+ if (owner_doc == frame_page.document) return frame_page;
+ }
+ return null;
+}
+
pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Element.Html.Script) !void {
- if (self.isGoingAway()) {
+ // Resolve the correct page for this script. If the script is in an iframe's
+ // document, dispatch to the iframe's page so it executes in the right V8 context.
+ const target_page = self.resolvePageForNode(script.asNode()) orelse self;
+
+ if (target_page.isGoingAway()) {
// if we're planning on navigating to another page, don't run this script
return;
}
@@ -1084,12 +1099,12 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele
}
}
- self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
+ target_page._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
log.err(.page, "page.scriptAddedCallback", .{
.err = err,
- .url = self.url,
+ .url = target_page.url,
.src = script.asElement().getAttributeSafe(comptime .wrap("src")),
- .type = self._type,
+ .type = target_page._type,
});
};
}
@@ -2749,6 +2764,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
// nodeComplete() callback is executed.
try self.nodeIsReady(false, child);
+ // StealthPanda: when a node becomes connected and has a shadow root,
+ // also process shadow root children (e.g., iframes in shadow DOM).
+ // Without this, iframes in closed shadow DOMs never get iframeAddedCallback.
+ if (parent_is_connected) {
+ if (child.is(Element)) |el| {
+ if (self._element_shadow_roots.get(el)) |shadow_root| {
+ var shadow_child = shadow_root.asNode().firstChild();
+ while (shadow_child) |sc| {
+ try self.nodeIsReady(false, sc);
+ shadow_child = sc.nextSibling();
+ }
+ }
+ }
+ }
+
// Check if text was added to a script that hasn't started yet.
if (child._type == .cdata and parent_is_connected) {
if (parent.is(Element.Html.Script)) |script| {
@@ -2778,6 +2808,15 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
try self.addElementId(parent, el, id);
}
try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self);
+
+ // Process shadow root children (e.g., iframes in closed shadow DOM)
+ if (self._element_shadow_roots.get(el)) |shadow_root| {
+ var shadow_child = shadow_root.asDocumentFragment().asNode().firstChild();
+ while (shadow_child) |sc| {
+ try self.nodeIsReady(false, sc);
+ shadow_child = sc.nextSibling();
+ }
+ }
}
}
return;
diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig
index e91ce4a1b2..f90d1452ea 100644
--- a/src/browser/js/Env.zig
+++ b/src/browser/js/Env.zig
@@ -355,6 +355,15 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
self.contexts[count] = context;
self.context_count = count + 1;
+ // StealthPanda: inject anti-detection patches at context creation time,
+ // BEFORE any scripts (including async scripts) can run.
+ {
+ var stealth_ls: js.Local.Scope = undefined;
+ context.localScope(&stealth_ls);
+ defer stealth_ls.deinit();
+ stealth_ls.local.eval(@import("../../cdp/domains/stealth_inject.zig").script, null) catch {};
+ }
+
return context;
}
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 0a51327e3a..dcbdf6c870 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -865,4 +865,6 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/CryptoKey.zig"),
@import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"),
+ @import("../webapi/AudioContext.zig"),
+ @import("../webapi/Chrome.zig"),
});
diff --git a/src/browser/webapi/AudioContext.zig b/src/browser/webapi/AudioContext.zig
new file mode 100644
index 0000000000..9161a5686f
--- /dev/null
+++ b/src/browser/webapi/AudioContext.zig
@@ -0,0 +1,88 @@
+// StealthPanda: AudioContext stub for browser fingerprint consistency.
+// Cloudflare Turnstile and other bot detection systems check for the
+// existence of AudioContext. This stub provides the constructor and
+// basic properties without actual audio rendering.
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+pub fn registerTypes() []const type {
+ return &.{ AudioContext, BaseAudioContext };
+}
+
+const AudioContext = @This();
+
+_state: []const u8 = "suspended",
+
+pub fn init(page: *Page) !*AudioContext {
+ return page._factory.create(AudioContext{ ._state = "running" });
+}
+
+pub fn getState(self: *const AudioContext) []const u8 {
+ return self._state;
+}
+
+pub fn getSampleRate(_: *const AudioContext) f64 {
+ return 44100.0;
+}
+
+pub fn getCurrentTime(_: *const AudioContext) f64 {
+ return 0.0;
+}
+
+pub fn getBaseLatency(_: *const AudioContext) f64 {
+ return 0.005;
+}
+
+pub fn close(self: *AudioContext) void {
+ self._state = "closed";
+}
+
+pub fn resume_(self: *AudioContext) void {
+ if (!std.mem.eql(u8, self._state, "closed")) {
+ self._state = "running";
+ }
+}
+
+pub fn suspend_(self: *AudioContext) void {
+ if (!std.mem.eql(u8, self._state, "closed")) {
+ self._state = "suspended";
+ }
+}
+
+const std = @import("std");
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(AudioContext);
+
+ pub const Meta = struct {
+ pub const name = "AudioContext";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(AudioContext.init, .{});
+ pub const state = bridge.accessor(AudioContext.getState, null, .{});
+ pub const sampleRate = bridge.accessor(AudioContext.getSampleRate, null, .{});
+ pub const currentTime = bridge.accessor(AudioContext.getCurrentTime, null, .{});
+ pub const baseLatency = bridge.accessor(AudioContext.getBaseLatency, null, .{});
+ pub const close = bridge.function(AudioContext.close, .{});
+ pub const @"resume" = bridge.function(AudioContext.resume_, .{});
+ pub const @"suspend" = bridge.function(AudioContext.suspend_, .{});
+};
+
+/// BaseAudioContext is the parent interface.
+/// Some detection scripts check for its existence.
+pub const BaseAudioContext = struct {
+ _pad: bool = false,
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(BaseAudioContext);
+ pub const Meta = struct {
+ pub const name = "BaseAudioContext";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+ };
+};
diff --git a/src/browser/webapi/Chrome.zig b/src/browser/webapi/Chrome.zig
new file mode 100644
index 0000000000..2135e47153
--- /dev/null
+++ b/src/browser/webapi/Chrome.zig
@@ -0,0 +1,47 @@
+// StealthPanda: window.chrome stub.
+// Bot detection scripts check for the existence of window.chrome
+// and window.chrome.runtime to verify the browser is Chrome.
+
+const js = @import("../js/js.zig");
+
+pub fn registerTypes() []const type {
+ return &.{ Chrome, ChromeRuntime };
+}
+
+const Chrome = @This();
+
+_runtime: ChromeRuntime = .{},
+
+pub fn getRuntime(self: *Chrome) *ChromeRuntime {
+ return &self._runtime;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Chrome);
+
+ pub const Meta = struct {
+ pub const name = "Chrome";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const runtime = bridge.accessor(Chrome.getRuntime, null, .{});
+};
+
+pub const ChromeRuntime = struct {
+ _pad: bool = false,
+
+ pub fn connect(_: *const ChromeRuntime) void {}
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(ChromeRuntime);
+
+ pub const Meta = struct {
+ pub const name = "ChromeRuntime";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const connect = bridge.function(ChromeRuntime.connect, .{ .noop = true });
+ };
+};
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
index 764e3be4e7..eaef4dfc32 100644
--- a/src/browser/webapi/Document.zig
+++ b/src/browser/webapi/Document.zig
@@ -619,7 +619,27 @@ fn looksLikeNewDocument(html: []const u8) bool {
std.ascii.startsWithIgnoreCase(trimmed, ".
+const std = @import("std");
const js = @import("../js/js.zig");
pub fn registerTypes() []const type {
- return &.{ PluginArray, Plugin };
+ return &.{ PluginArray, Plugin, MimeTypeArray, MimeType };
}
const PluginArray = @This();
-_pad: bool = false,
+_plugins: [5]Plugin = chrome_plugins,
pub fn refresh(_: *const PluginArray) void {}
-pub fn getAtIndex(_: *const PluginArray, index: usize) ?*Plugin {
- _ = index;
- return null;
+
+pub fn getLength(_: *const PluginArray) u32 {
+ return chrome_plugins.len;
+}
+
+pub fn getAtIndex(self: *PluginArray, index: usize) ?*Plugin {
+ if (index >= chrome_plugins.len) return null;
+ return &self._plugins[index];
}
-pub fn getByName(_: *const PluginArray, name: []const u8) ?*Plugin {
- _ = name;
+pub fn getByName(self: *PluginArray, name: []const u8) ?*Plugin {
+ for (&self._plugins) |*p| {
+ if (std.mem.eql(u8, p.name, name)) return p;
+ }
return null;
}
-// Cannot be constructed, and we currently never return any, so no reason to
-// implement anything on it (for now)
-const Plugin = struct {
+pub const Plugin = struct {
+ name: [:0]const u8 = "",
+ filename: [:0]const u8 = "",
+ description: [:0]const u8 = "",
+ mime_types: []const MimeType = &.{},
+
+ pub fn getName(self: *const Plugin) [:0]const u8 {
+ return self.name;
+ }
+
+ pub fn getFilename(self: *const Plugin) [:0]const u8 {
+ return self.filename;
+ }
+
+ pub fn getDescription(self: *const Plugin) [:0]const u8 {
+ return self.description;
+ }
+
+ pub fn getLength(self: *const Plugin) u32 {
+ return @intCast(self.mime_types.len);
+ }
+
+ pub fn getAtIndex(_: *const Plugin, _: usize) ?*MimeType {
+ return null;
+ }
+
pub const JsApi = struct {
pub const bridge = js.Bridge(Plugin);
pub const Meta = struct {
pub const name = "Plugin";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const name = bridge.accessor(Plugin.getName, null, .{});
+ pub const filename = bridge.accessor(Plugin.getFilename, null, .{});
+ pub const description = bridge.accessor(Plugin.getDescription, null, .{});
+ pub const length = bridge.accessor(Plugin.getLength, null, .{});
+ pub const @"[int]" = bridge.indexed(Plugin.getAtIndex, null, .{ .null_as_undefined = true });
+ };
+};
+
+pub const MimeType = struct {
+ type_str: [:0]const u8 = "",
+ suffixes: [:0]const u8 = "",
+ description: [:0]const u8 = "",
+
+ pub fn getType(self: *const MimeType) [:0]const u8 {
+ return self.type_str;
+ }
+
+ pub fn getSuffixes(self: *const MimeType) [:0]const u8 {
+ return self.suffixes;
+ }
+
+ pub fn getDescription(self: *const MimeType) [:0]const u8 {
+ return self.description;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(MimeType);
+ pub const Meta = struct {
+ pub const name = "MimeType";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const @"type" = bridge.accessor(MimeType.getType, null, .{});
+ pub const suffixes = bridge.accessor(MimeType.getSuffixes, null, .{});
+ pub const description = bridge.accessor(MimeType.getDescription, null, .{});
+ };
+};
+
+pub const MimeTypeArray = struct {
+ _pad: bool = false,
+
+ pub fn getLength(_: *const MimeTypeArray) u32 {
+ return pdf_mime_types.len;
+ }
+
+ pub fn getAtIndex(_: *const MimeTypeArray, _: usize) ?*MimeType {
+ return null;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(MimeTypeArray);
+ pub const Meta = struct {
+ pub const name = "MimeTypeArray";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
+
+ pub const length = bridge.accessor(MimeTypeArray.getLength, null, .{});
+ pub const @"[int]" = bridge.indexed(MimeTypeArray.getAtIndex, null, .{ .null_as_undefined = true });
};
};
+// Chrome's standard PDF mime types
+const pdf_mime_types = [_]MimeType{
+ .{ .type_str = "application/pdf", .suffixes = "pdf", .description = "Portable Document Format" },
+ .{ .type_str = "text/pdf", .suffixes = "pdf", .description = "Portable Document Format" },
+};
+
+// Chrome's 5 standard PDF plugins
+const chrome_plugins = [5]Plugin{
+ .{ .name = "PDF Viewer", .filename = "internal-pdf-viewer", .description = "Portable Document Format", .mime_types = &pdf_mime_types },
+ .{ .name = "Chrome PDF Viewer", .filename = "internal-pdf-viewer", .description = "Portable Document Format", .mime_types = &pdf_mime_types },
+ .{ .name = "Chromium PDF Viewer", .filename = "internal-pdf-viewer", .description = "Portable Document Format", .mime_types = &pdf_mime_types },
+ .{ .name = "Microsoft Edge PDF Viewer", .filename = "internal-pdf-viewer", .description = "Portable Document Format", .mime_types = &pdf_mime_types },
+ .{ .name = "WebKit built-in PDF", .filename = "internal-pdf-viewer", .description = "Portable Document Format", .mime_types = &pdf_mime_types },
+};
+
pub const JsApi = struct {
pub const bridge = js.Bridge(PluginArray);
@@ -61,12 +169,12 @@ pub const JsApi = struct {
pub const empty_with_no_proto = true;
};
- pub const length = bridge.property(0, .{ .template = false });
+ pub const length = bridge.accessor(PluginArray.getLength, null, .{});
pub const refresh = bridge.function(PluginArray.refresh, .{});
pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, .{ .null_as_undefined = true });
pub const item = bridge.function(_item, .{});
- fn _item(self: *const PluginArray, index: i32) ?*Plugin {
+ fn _item(self: *PluginArray, index: i32) ?*Plugin {
if (index < 0) {
return null;
}
diff --git a/src/browser/webapi/Screen.zig b/src/browser/webapi/Screen.zig
index f14027196d..57eda75b23 100644
--- a/src/browser/webapi/Screen.zig
+++ b/src/browser/webapi/Screen.zig
@@ -36,6 +36,23 @@ pub fn asEventTarget(self: *Screen) *EventTarget {
return self._proto;
}
+pub fn getWidth(_: *const Screen, page: *Page) u32 {
+ return page._session.browser.app.config.screenWidth();
+}
+
+pub fn getHeight(_: *const Screen, page: *Page) u32 {
+ return page._session.browser.app.config.screenHeight();
+}
+
+pub fn getAvailWidth(_: *const Screen, page: *Page) u32 {
+ return page._session.browser.app.config.screenWidth();
+}
+
+pub fn getAvailHeight(_: *const Screen, page: *Page) u32 {
+ // Subtract 40px for taskbar, matching Chrome behavior
+ return page._session.browser.app.config.screenHeight() -| 40;
+}
+
pub fn getOrientation(self: *Screen, page: *Page) !*Orientation {
if (self._orientation) |orientation| {
return orientation;
@@ -54,10 +71,10 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
- pub const width = bridge.property(1920, .{ .template = false });
- pub const height = bridge.property(1080, .{ .template = false });
- pub const availWidth = bridge.property(1920, .{ .template = false });
- pub const availHeight = bridge.property(1040, .{ .template = false });
+ pub const width = bridge.accessor(Screen.getWidth, null, .{});
+ pub const height = bridge.accessor(Screen.getHeight, null, .{});
+ pub const availWidth = bridge.accessor(Screen.getAvailWidth, null, .{});
+ pub const availHeight = bridge.accessor(Screen.getAvailHeight, null, .{});
pub const colorDepth = bridge.property(24, .{ .template = false });
pub const pixelDepth = bridge.property(24, .{ .template = false });
pub const orientation = bridge.accessor(Screen.getOrientation, null, .{});
diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig
index a5e54923af..534ccfea75 100644
--- a/src/browser/webapi/ShadowRoot.zig
+++ b/src/browser/webapi/ShadowRoot.zig
@@ -125,6 +125,23 @@ pub const JsApi = struct {
pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
+
+ // Node mutation methods must be explicitly defined because V8's template
+ // prototype chain doesn't inherit them from Node for ShadowRoot instances.
+ pub const appendChild = bridge.function(_appendChild, .{});
+ fn _appendChild(self: *ShadowRoot, child: *Node, page: *Page) *Node {
+ return self.asDocumentFragment().asNode().appendChild(child, page) catch child;
+ }
+
+ pub const insertBefore = bridge.function(_srInsertBefore, .{ .dom_exception = true });
+ fn _srInsertBefore(self: *ShadowRoot, new_node: *Node, ref_node: ?*Node, page: *Page) !*Node {
+ return self.asDocumentFragment().asNode().insertBefore(new_node, ref_node, page);
+ }
+
+ pub const removeChild = bridge.function(_srRemoveChild, .{ .dom_exception = true });
+ fn _srRemoveChild(self: *ShadowRoot, child: *Node, page: *Page) !*Node {
+ return self.asDocumentFragment().asNode().removeChild(child, page);
+ }
pub const getElementById = bridge.function(_getElementById, .{});
fn _getElementById(self: *ShadowRoot, value_: ?js.Value, page: *Page) !?*Element {
const value = value_ orelse return null;
diff --git a/src/browser/webapi/VisualViewport.zig b/src/browser/webapi/VisualViewport.zig
index 15adb3926c..9c00f320e0 100644
--- a/src/browser/webapi/VisualViewport.zig
+++ b/src/browser/webapi/VisualViewport.zig
@@ -36,6 +36,14 @@ pub fn getPageTop(_: *const VisualViewport, page: *Page) u32 {
return page.window.getScrollY();
}
+pub fn getWidth(_: *const VisualViewport, page: *Page) u32 {
+ return page._session.browser.app.config.screenWidth();
+}
+
+pub fn getHeight(_: *const VisualViewport, page: *Page) u32 {
+ return page._session.browser.app.config.screenHeight();
+}
+
pub const JsApi = struct {
pub const bridge = js.Bridge(VisualViewport);
@@ -51,7 +59,7 @@ pub const JsApi = struct {
pub const offsetTop = bridge.property(0, .{ .template = false });
pub const pageLeft = bridge.accessor(VisualViewport.getPageLeft, null, .{});
pub const pageTop = bridge.accessor(VisualViewport.getPageTop, null, .{});
- pub const width = bridge.property(1920, .{ .template = false });
- pub const height = bridge.property(1080, .{ .template = false });
+ pub const width = bridge.accessor(VisualViewport.getWidth, null, .{});
+ pub const height = bridge.accessor(VisualViewport.getHeight, null, .{});
pub const scale = bridge.property(1.0, .{ .template = false });
};
diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig
index fb3ec8f83d..df858a0de6 100644
--- a/src/browser/webapi/Window.zig
+++ b/src/browser/webapi/Window.zig
@@ -44,6 +44,7 @@ const Element = @import("Element.zig");
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const CustomElementRegistry = @import("CustomElementRegistry.zig");
const Selection = @import("Selection.zig");
+const Chrome = @import("Chrome.zig");
const IS_DEBUG = builtin.mode == .Debug;
@@ -62,6 +63,7 @@ _css: CSS = .init,
_crypto: Crypto = .init,
_console: Console = .init,
_navigator: Navigator = .init,
+_chrome: Chrome = .{},
_screen: *Screen,
_visual_viewport: *VisualViewport,
_performance: Performance,
@@ -118,8 +120,16 @@ pub fn getTop(self: *Window, page: *Page) Access {
return Access.init(page.window, p.window);
}
+/// When an iframe accesses window.parent, save the caller's page
+/// so postMessage can use it for the origin.
+var _last_cross_context_caller: ?*Page = null;
+
pub fn getParent(self: *Window, page: *Page) Access {
if (self._page.parent) |p| {
+ // Save the caller's page for postMessage origin resolution
+ if (page != p) {
+ _last_cross_context_caller = page;
+ }
return Access.init(page.window, p.window);
}
return .{ .window = self };
@@ -137,10 +147,48 @@ pub fn getNavigator(self: *Window) *Navigator {
return &self._navigator;
}
+pub fn getChrome(self: *Window) *Chrome {
+ return &self._chrome;
+}
+
pub fn getScreen(self: *Window) *Screen {
return self._screen;
}
+pub fn getOrigin(self: *const Window) []const u8 {
+ // about:blank inherits origin from parent
+ if (std.mem.eql(u8, self._page.url, "about:blank")) {
+ if (self._page.parent) |parent| {
+ return parent.origin orelse "null";
+ }
+ }
+ return self._page.origin orelse "null";
+}
+
+pub fn getCrossOriginIsolated(_: *const Window) bool {
+ return false;
+}
+
+pub fn getIsSecureContext(self: *const Window) bool {
+ const url = self._page.url;
+ if (std.ascii.startsWithIgnoreCase(url, "https://")) return true;
+ // about:blank inherits secure context from parent
+ if (std.mem.eql(u8, url, "about:blank")) {
+ if (self._page.parent) |parent| {
+ return std.ascii.startsWithIgnoreCase(parent.url, "https://");
+ }
+ }
+ return false;
+}
+
+pub fn getInnerWidth(self: *const Window) u32 {
+ return self._page._session.browser.app.config.screenWidth();
+}
+
+pub fn getInnerHeight(self: *const Window) u32 {
+ return self._page._session.browser.app.config.screenHeight();
+}
+
pub fn getVisualViewport(self: *const Window) *VisualViewport {
return self._visual_viewport;
}
@@ -381,10 +429,12 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
}
}
-pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQueryList {
+pub fn matchMedia(self: *const Window, query: []const u8, page: *Page) !*MediaQueryList {
+ const screen_width = self._page._session.browser.app.config.screenWidth();
return page._factory.eventTarget(MediaQueryList{
._proto = undefined,
._media = try page.dupeString(query),
+ ._matches = MediaQueryList.evaluateQuery(query, screen_width),
});
}
@@ -403,15 +453,20 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
_ = target_origin;
// self = the window that will get the message
- // page = the context calling postMessage
+ // page = the page of the *target* context (self's page), NOT the caller
+ // We need the caller's window, which is the incumbent context's window
const target_page = self._page;
const source_window = target_page.js.getIncumbent().window;
const arena = try target_page.getArena(.{ .debug = "Window.postMessage" });
errdefer target_page.releaseArena(arena);
- // Origin should be the source window's origin (where the message came from)
- const origin = try source_window._location.getOrigin(page);
+ // Use the cross-context caller's origin if available (set during getParent/getTop).
+ // This correctly identifies the iframe's origin when the iframe calls
+ // parent.postMessage(), even though getIncumbent() returns the parent context.
+ const caller_page = _last_cross_context_caller orelse source_window._page;
+ _last_cross_context_caller = null; // consume
+ const origin = caller_page.origin orelse try source_window._location.getOrigin(page);
const callback = try arena.create(PostMessageCallback);
callback.* = .{
.arena = arena,
@@ -888,14 +943,13 @@ pub const JsApi = struct {
pub const scroll = bridge.function(Window.scrollTo, .{});
pub const scrollBy = bridge.function(Window.scrollBy, .{});
- // Return false since we don't have secure-context-only APIs implemented
- // (webcam, geolocation, clipboard, etc.)
- // This is safer and could help avoid processing errors by hinting at
- // sites not to try to access those features
- pub const isSecureContext = bridge.property(false, .{ .template = false });
+ pub const isSecureContext = bridge.accessor(Window.getIsSecureContext, null, .{});
+ pub const origin = bridge.accessor(Window.getOrigin, null, .{});
+ pub const crossOriginIsolated = bridge.accessor(Window.getCrossOriginIsolated, null, .{});
- pub const innerWidth = bridge.property(1920, .{ .template = false });
- pub const innerHeight = bridge.property(1080, .{ .template = false });
+ pub const innerWidth = bridge.accessor(Window.getInnerWidth, null, .{});
+ pub const innerHeight = bridge.accessor(Window.getInnerHeight, null, .{});
+ pub const chrome = bridge.accessor(Window.getChrome, null, .{});
pub const devicePixelRatio = bridge.property(1, .{ .template = false });
// This should return a window-like object in specific conditions. Would be
@@ -917,7 +971,7 @@ pub const JsApi = struct {
}.prompt, .{});
};
-const CrossOriginWindow = struct {
+pub const CrossOriginWindow = struct {
window: *Window,
pub fn postMessage(self: *CrossOriginWindow, message: js.Value.Temp, target_origin: ?[]const u8, page: *Page) !void {
diff --git a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig
index d8ac54f25b..4b49bc41b1 100644
--- a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig
+++ b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig
@@ -1,22 +1,13 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
-//
-// Francis Bouvier
-// Pierre Tachoire
+// Modified by StealthPanda contributors.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
const std = @import("std");
+const z2d = @import("z2d");
const js = @import("../../js/js.zig");
@@ -26,35 +17,49 @@ const Page = @import("../../Page.zig");
const Canvas = @import("../element/html/Canvas.zig");
const ImageData = @import("../ImageData.zig");
-/// This class doesn't implement a `constructor`.
-/// It can be obtained with a call to `HTMLCanvasElement#getContext`.
+const Allocator = std.mem.Allocator;
+
+/// Canvas 2D rendering context backed by z2d for real pixel rendering.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
const CanvasRenderingContext2D = @This();
-/// Reference to the parent canvas element.
-/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/canvas
+
_canvas: *Canvas,
-/// Fill color.
-/// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
_fill_style: color.RGBA = color.RGBA.Named.black,
+_stroke_color: color.RGBA = color.RGBA.Named.black,
+_line_width: f64 = 1.0,
+
+// z2d rendering state — lazily initialized on first draw
+_surface: ?z2d.Surface = null,
+_alloc: ?Allocator = null,
pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
return self._canvas;
}
+/// Ensure the z2d surface exists, creating it on first use.
+fn ensureSurface(self: *CanvasRenderingContext2D, page: *Page) void {
+ if (self._surface != null) return;
+ const w = self._canvas.getWidth();
+ const h = self._canvas.getHeight();
+ const alloc = page._factory._arena;
+ self._alloc = alloc;
+ self._surface = z2d.Surface.init(.image_surface_rgba, alloc, @intCast(w), @intCast(h)) catch null;
+}
+
pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 {
var w = std.Io.Writer.Allocating.init(page.call_arena);
try self._fill_style.format(&w.writer);
return w.written();
}
-pub fn setFillStyle(
- self: *CanvasRenderingContext2D,
- value: []const u8,
-) !void {
- // Prefer the same fill_style if fails.
+pub fn setFillStyle(self: *CanvasRenderingContext2D, value: []const u8) !void {
self._fill_style = color.RGBA.parse(value) catch self._fill_style;
}
+pub fn setStrokeStyle(self: *CanvasRenderingContext2D, value: []const u8) void {
+ self._stroke_color = color.RGBA.parse(value) catch self._stroke_color;
+}
+
const WidthOrImageData = union(enum) {
width: u32,
image_data: *ImageData,
@@ -63,9 +68,7 @@ const WidthOrImageData = union(enum) {
pub fn createImageData(
_: *const CanvasRenderingContext2D,
width_or_image_data: WidthOrImageData,
- /// If `ImageData` variant preferred, this is null.
maybe_height: ?u32,
- /// Can be used if width and height provided.
maybe_settings: ?ImageData.ConstructorSettings,
page: *Page,
) !*ImageData {
@@ -83,7 +86,7 @@ pub fn createImageData(
pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
pub fn getImageData(
- _: *const CanvasRenderingContext2D,
+ self: *CanvasRenderingContext2D,
_: i32, // sx
_: i32, // sy
sw: i32,
@@ -93,9 +96,89 @@ pub fn getImageData(
if (sw <= 0 or sh <= 0) {
return error.IndexSizeError;
}
+
+ // Ensure the surface exists so we have rendered pixels
+ self.ensureSurface(page);
+
return ImageData.init(@intCast(sw), @intCast(sh), null, page);
}
+fn fillToZ2dPixel(self: *const CanvasRenderingContext2D) z2d.Pixel {
+ return .{ .rgba = .{
+ .r = self._fill_style.r,
+ .g = self._fill_style.g,
+ .b = self._fill_style.b,
+ .a = self._fill_style.a,
+ } };
+}
+
+fn strokeToZ2dPixel(self: *const CanvasRenderingContext2D) z2d.Pixel {
+ return .{ .rgba = .{
+ .r = self._stroke_color.r,
+ .g = self._stroke_color.g,
+ .b = self._stroke_color.b,
+ .a = self._stroke_color.a,
+ } };
+}
+
+// === Drawing operations ===
+
+pub fn clearRect(self: *CanvasRenderingContext2D, x: f64, y: f64, w: f64, h: f64, page: *Page) void {
+ self.ensureSurface(page);
+ const sfc = &(self._surface orelse return);
+ // Fill with transparent pixels
+ const xi: i32 = @intFromFloat(x);
+ const yi: i32 = @intFromFloat(y);
+ const wi: i32 = @intFromFloat(w);
+ const hi: i32 = @intFromFloat(h);
+ var py: i32 = yi;
+ while (py < yi + hi) : (py += 1) {
+ var px: i32 = xi;
+ while (px < xi + wi) : (px += 1) {
+ if (px >= 0 and py >= 0) {
+ sfc.putPixel(@intCast(px), @intCast(py), .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } });
+ }
+ }
+ }
+}
+
+pub fn fillRect(self: *CanvasRenderingContext2D, x: f64, y: f64, w: f64, h: f64, page: *Page) void {
+ self.ensureSurface(page);
+ var sfc = self._surface orelse return;
+ var ctx = z2d.Context.init(self._alloc orelse return, &sfc);
+ defer ctx.deinit();
+
+ ctx.setSourceToPixel(self.fillToZ2dPixel());
+ ctx.moveTo(x, y) catch return;
+ ctx.lineTo(x + w, y) catch return;
+ ctx.lineTo(x + w, y + h) catch return;
+ ctx.lineTo(x, y + h) catch return;
+ ctx.closePath() catch return;
+ ctx.fill() catch return;
+ self._surface = sfc;
+}
+
+pub fn strokeRect(self: *CanvasRenderingContext2D, x: f64, y: f64, w: f64, h: f64, page: *Page) void {
+ self.ensureSurface(page);
+ var sfc = self._surface orelse return;
+ var ctx = z2d.Context.init(self._alloc orelse return, &sfc);
+ defer ctx.deinit();
+
+ ctx.setSourceToPixel(self.strokeToZ2dPixel());
+ ctx.setLineWidth(self._line_width);
+ ctx.moveTo(x, y) catch return;
+ ctx.lineTo(x + w, y) catch return;
+ ctx.lineTo(x + w, y + h) catch return;
+ ctx.lineTo(x, y + h) catch return;
+ ctx.closePath() catch return;
+ ctx.stroke() catch return;
+ self._surface = sfc;
+}
+
+// === Path-based drawing (stateless — each call is independent) ===
+// For proper path accumulation we'd need a path buffer. For now,
+// fillText and basic shapes work which is enough for Picasso fingerprinting.
+
pub fn save(_: *CanvasRenderingContext2D) void {}
pub fn restore(_: *CanvasRenderingContext2D) void {}
pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {}
@@ -104,10 +187,6 @@ pub fn translate(_: *CanvasRenderingContext2D, _: f64, _: f64) void {}
pub fn transform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn setTransform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn resetTransform(_: *CanvasRenderingContext2D) void {}
-pub fn setStrokeStyle(_: *CanvasRenderingContext2D, _: []const u8) void {}
-pub fn clearRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
-pub fn fillRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
-pub fn strokeRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn beginPath(_: *CanvasRenderingContext2D) void {}
pub fn closePath(_: *CanvasRenderingContext2D) void {}
pub fn moveTo(_: *CanvasRenderingContext2D, _: f64, _: f64) void {}
@@ -120,8 +199,38 @@ pub fn rect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {
pub fn fill(_: *CanvasRenderingContext2D) void {}
pub fn stroke(_: *CanvasRenderingContext2D) void {}
pub fn clip(_: *CanvasRenderingContext2D) void {}
-pub fn fillText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {}
+pub fn fillText(self: *CanvasRenderingContext2D, _: []const u8, x: f64, y: f64, _: ?f64, page: *Page) void {
+ // Render a colored block to produce non-zero canvas fingerprint
+ self.ensureSurface(page);
+ var sfc = self._surface orelse return;
+ var ctx = z2d.Context.init(self._alloc orelse return, &sfc);
+ defer ctx.deinit();
+
+ ctx.setSourceToPixel(self.fillToZ2dPixel());
+ // Draw a small filled rectangle as text placeholder
+ const h: f64 = 10.0;
+ const w: f64 = 60.0;
+ ctx.moveTo(x, y - h) catch return;
+ ctx.lineTo(x + w, y - h) catch return;
+ ctx.lineTo(x + w, y) catch return;
+ ctx.lineTo(x, y) catch return;
+ ctx.closePath() catch return;
+ ctx.fill() catch return;
+ self._surface = sfc;
+}
pub fn strokeText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {}
+pub fn drawImage(_: *CanvasRenderingContext2D, _: ?*anyopaque, _: f64, _: f64) void {}
+pub fn setLineDash(_: *CanvasRenderingContext2D) void {}
+pub fn isPointInPath(_: *CanvasRenderingContext2D, _: f64, _: f64) bool {
+ return false;
+}
+
+/// Get the raw RGBA pixel buffer from the surface, or null if not initialized.
+pub fn getPixelBuffer(self: *CanvasRenderingContext2D) ?[]const u8 {
+ const sfc = self._surface orelse return null;
+ const buf = sfc.image_surface_rgba.buf;
+ return std.mem.sliceAsBytes(buf);
+}
pub const JsApi = struct {
pub const bridge = js.Bridge(CanvasRenderingContext2D);
@@ -148,7 +257,7 @@ pub const JsApi = struct {
pub const fillStyle = bridge.accessor(CanvasRenderingContext2D.getFillStyle, CanvasRenderingContext2D.setFillStyle, .{});
pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true });
- pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{ .noop = true });
+ pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{});
pub const getImageData = bridge.function(CanvasRenderingContext2D.getImageData, .{ .dom_exception = true });
pub const save = bridge.function(CanvasRenderingContext2D.save, .{ .noop = true });
pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{ .noop = true });
@@ -158,9 +267,9 @@ pub const JsApi = struct {
pub const transform = bridge.function(CanvasRenderingContext2D.transform, .{ .noop = true });
pub const setTransform = bridge.function(CanvasRenderingContext2D.setTransform, .{ .noop = true });
pub const resetTransform = bridge.function(CanvasRenderingContext2D.resetTransform, .{ .noop = true });
- pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{ .noop = true });
- pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{ .noop = true });
- pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{ .noop = true });
+ pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{});
+ pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{});
+ pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{});
pub const beginPath = bridge.function(CanvasRenderingContext2D.beginPath, .{ .noop = true });
pub const closePath = bridge.function(CanvasRenderingContext2D.closePath, .{ .noop = true });
pub const moveTo = bridge.function(CanvasRenderingContext2D.moveTo, .{ .noop = true });
@@ -173,8 +282,11 @@ pub const JsApi = struct {
pub const fill = bridge.function(CanvasRenderingContext2D.fill, .{ .noop = true });
pub const stroke = bridge.function(CanvasRenderingContext2D.stroke, .{ .noop = true });
pub const clip = bridge.function(CanvasRenderingContext2D.clip, .{ .noop = true });
- pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{ .noop = true });
+ pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{});
pub const strokeText = bridge.function(CanvasRenderingContext2D.strokeText, .{ .noop = true });
+ pub const drawImage = bridge.function(CanvasRenderingContext2D.drawImage, .{ .noop = true });
+ pub const setLineDash = bridge.function(CanvasRenderingContext2D.setLineDash, .{ .noop = true });
+ pub const isPointInPath = bridge.function(CanvasRenderingContext2D.isPointInPath, .{});
};
const testing = @import("../../../testing.zig");
diff --git a/src/browser/webapi/canvas/WebGLRenderingContext.zig b/src/browser/webapi/canvas/WebGLRenderingContext.zig
index decd41c4e8..1cb8a3cab4 100644
--- a/src/browser/webapi/canvas/WebGLRenderingContext.zig
+++ b/src/browser/webapi/canvas/WebGLRenderingContext.zig
@@ -159,12 +159,51 @@ pub const Extension = union(enum) {
};
};
-/// This actually takes "GLenum" which, in fact, is a fancy way to say number.
-/// Return value also depends on what's being passed as `pname`; we don't really
-/// support any though.
+/// Returns WebGL parameters. Real WebGL returns polymorphic types but we
+/// return strings for bridge compatibility. Bot detection primarily checks
+/// UNMASKED_VENDOR_WEBGL (0x9245) and UNMASKED_RENDERER_WEBGL (0x9246).
pub fn getParameter(_: *const WebGLRenderingContext, pname: u32) []const u8 {
- _ = pname;
- return "";
+ return switch (pname) {
+ // UNMASKED_VENDOR_WEBGL — GPU vendor via WEBGL_debug_renderer_info
+ 0x9245 => "Google Inc. (NVIDIA)",
+ // UNMASKED_RENDERER_WEBGL — GPU renderer via WEBGL_debug_renderer_info
+ 0x9246 => "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)",
+ // VENDOR
+ 0x1F00 => "WebKit",
+ // RENDERER
+ 0x1F01 => "WebKit WebGL",
+ // VERSION
+ 0x1F02 => "WebGL 1.0 (OpenGL ES 2.0 Chromium)",
+ // SHADING_LANGUAGE_VERSION
+ 0x8B8C => "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)",
+ // MAX_TEXTURE_SIZE
+ 0x0D33 => "16384",
+ // MAX_RENDERBUFFER_SIZE
+ 0x84E8 => "16384",
+ // MAX_VIEWPORT_DIMS — would be an array, return as string
+ 0x0D3A => "32767,32767",
+ // MAX_VERTEX_ATTRIBS
+ 0x8869 => "16",
+ // MAX_VERTEX_UNIFORM_VECTORS
+ 0x8DFB => "4096",
+ // MAX_VARYING_VECTORS
+ 0x8DFC => "30",
+ // MAX_FRAGMENT_UNIFORM_VECTORS
+ 0x8DFD => "1024",
+ // MAX_TEXTURE_IMAGE_UNITS
+ 0x8872 => "16",
+ // MAX_VERTEX_TEXTURE_IMAGE_UNITS
+ 0x8B4C => "16",
+ // MAX_COMBINED_TEXTURE_IMAGE_UNITS
+ 0x8B4D => "32",
+ // MAX_CUBE_MAP_TEXTURE_SIZE
+ 0x851C => "16384",
+ // ALIASED_LINE_WIDTH_RANGE
+ 0x846E => "1,1",
+ // ALIASED_POINT_SIZE_RANGE
+ 0x846D => "1,1024",
+ else => "",
+ };
}
/// Enables a WebGL extension.
diff --git a/src/browser/webapi/canvas/png_encode.zig b/src/browser/webapi/canvas/png_encode.zig
new file mode 100644
index 0000000000..486a2ba86b
--- /dev/null
+++ b/src/browser/webapi/canvas/png_encode.zig
@@ -0,0 +1,126 @@
+// Minimal PNG encoder for Canvas toDataURL().
+// Produces valid PNG from raw RGBA pixel data using store-only zlib.
+
+const std = @import("std");
+
+const PNG_SIGNATURE = "\x89PNG\r\n\x1a\n";
+
+/// Encode RGBA pixel data as PNG, return base64-encoded data URL.
+pub fn encodeToDataURL(alloc: std.mem.Allocator, rgba: []const u8, width: u32, height: u32) ![]const u8 {
+ var png_buf: std.ArrayList(u8) = .empty;
+ defer png_buf.deinit(alloc);
+
+ try png_buf.appendSlice(alloc, PNG_SIGNATURE);
+
+ // IHDR chunk
+ var ihdr: [13]u8 = undefined;
+ std.mem.writeInt(u32, ihdr[0..4], width, .big);
+ std.mem.writeInt(u32, ihdr[4..8], height, .big);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // color type: RGBA
+ ihdr[10] = 0; // compression
+ ihdr[11] = 0; // filter
+ ihdr[12] = 0; // interlace
+ try writeChunk(alloc, &png_buf, "IHDR", &ihdr);
+
+ // Build raw scanline data (filter byte 0 + row data for each row)
+ const row_bytes = width * 4;
+ const row_size = row_bytes + 1; // +1 for filter byte
+ const raw_size: usize = row_size * height;
+ const raw = try alloc.alloc(u8, raw_size);
+ defer alloc.free(raw);
+
+ for (0..height) |y| {
+ const row_start = y * row_size;
+ raw[row_start] = 0; // filter: None
+ const src_start = y * row_bytes;
+ const src_end = src_start + row_bytes;
+ if (src_end <= rgba.len) {
+ @memcpy(raw[row_start + 1 .. row_start + row_size], rgba[src_start..src_end]);
+ } else {
+ @memset(raw[row_start + 1 .. row_start + row_size], 0);
+ }
+ }
+
+ // IDAT: wrap raw data in zlib stored blocks (no compression)
+ var idat_buf: std.ArrayList(u8) = .empty;
+ defer idat_buf.deinit(alloc);
+ try zlibStore(alloc, &idat_buf, raw);
+
+ try writeChunk(alloc, &png_buf, "IDAT", idat_buf.items);
+
+ // IEND
+ try writeChunk(alloc, &png_buf, "IEND", &.{});
+
+ // Base64 encode to data URL
+ const b64_len = std.base64.standard.Encoder.calcSize(png_buf.items.len);
+ const prefix = "data:image/png;base64,";
+ const result = try alloc.alloc(u8, prefix.len + b64_len);
+ @memcpy(result[0..prefix.len], prefix);
+ _ = std.base64.standard.Encoder.encode(result[prefix.len..], png_buf.items);
+
+ return result;
+}
+
+/// Wrap data in zlib format with stored (no compression) deflate blocks.
+fn zlibStore(alloc: std.mem.Allocator, out: *std.ArrayList(u8), data: []const u8) !void {
+ // Zlib header: CMF=0x78 (deflate, window=32K), FLG=0x01 (no dict, check bits)
+ try out.appendSlice(alloc, &.{ 0x78, 0x01 });
+
+ // Write stored deflate blocks (max 65535 bytes each)
+ const max_block: usize = 65535;
+ var offset: usize = 0;
+ while (offset < data.len) {
+ const remaining = data.len - offset;
+ const block_len: u16 = @intCast(@min(remaining, max_block));
+ const is_final: u8 = if (offset + block_len >= data.len) 1 else 0;
+
+ // Block header: BFINAL (1 bit) + BTYPE=00 (stored, 2 bits) + padding to byte
+ try out.append(alloc, is_final); // bfinal=is_final, btype=00 (stored)
+ // LEN and NLEN (little-endian)
+ var len_bytes: [2]u8 = undefined;
+ std.mem.writeInt(u16, &len_bytes, block_len, .little);
+ try out.appendSlice(alloc, &len_bytes);
+ var nlen_bytes: [2]u8 = undefined;
+ std.mem.writeInt(u16, &nlen_bytes, ~block_len, .little);
+ try out.appendSlice(alloc, &nlen_bytes);
+ // Block data
+ try out.appendSlice(alloc, data[offset .. offset + block_len]);
+ offset += block_len;
+ }
+
+ // Handle empty data
+ if (data.len == 0) {
+ try out.appendSlice(alloc, &.{ 0x01, 0x00, 0x00, 0xFF, 0xFF });
+ }
+
+ // Adler-32 checksum (big-endian)
+ const adler = adler32(data);
+ var adler_bytes: [4]u8 = undefined;
+ std.mem.writeInt(u32, &adler_bytes, adler, .big);
+ try out.appendSlice(alloc, &adler_bytes);
+}
+
+fn adler32(data: []const u8) u32 {
+ var a: u32 = 1;
+ var b: u32 = 0;
+ for (data) |byte| {
+ a = (a + byte) % 65521;
+ b = (b + a) % 65521;
+ }
+ return (b << 16) | a;
+}
+
+fn writeChunk(alloc: std.mem.Allocator, buf: *std.ArrayList(u8), chunk_type: *const [4]u8, data: []const u8) !void {
+ var len_bytes: [4]u8 = undefined;
+ std.mem.writeInt(u32, &len_bytes, @intCast(data.len), .big);
+ try buf.appendSlice(alloc, &len_bytes);
+ try buf.appendSlice(alloc, chunk_type);
+ try buf.appendSlice(alloc, data);
+ var crc = std.hash.crc.Crc32IsoHdlc.init();
+ crc.update(chunk_type);
+ crc.update(data);
+ var crc_bytes: [4]u8 = undefined;
+ std.mem.writeInt(u32, &crc_bytes, crc.final(), .big);
+ try buf.appendSlice(alloc, &crc_bytes);
+}
diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig
index 535c926dbc..c2302c3027 100644
--- a/src/browser/webapi/css/MediaQueryList.zig
+++ b/src/browser/webapi/css/MediaQueryList.zig
@@ -25,6 +25,7 @@ const MediaQueryList = @This();
_proto: *EventTarget,
_media: []const u8,
+_matches: bool = true,
pub fn deinit(self: *MediaQueryList) void {
_ = self;
@@ -38,6 +39,68 @@ pub fn getMedia(self: *const MediaQueryList) []const u8 {
return self._media;
}
+pub fn getMatches(self: *const MediaQueryList) bool {
+ return self._matches;
+}
+
+/// Basic media query evaluation. Handles common queries that bot detection checks.
+pub fn evaluateQuery(query: []const u8, screen_width: u32) bool {
+ const trimmed = std.mem.trim(u8, query, &std.ascii.whitespace);
+
+ // "(prefers-color-scheme: dark)" -> false (we're light)
+ if (std.mem.indexOf(u8, trimmed, "prefers-color-scheme") != null) {
+ return std.mem.indexOf(u8, trimmed, "light") != null;
+ }
+
+ // "(prefers-reduced-motion: ...)" -> no-preference
+ if (std.mem.indexOf(u8, trimmed, "prefers-reduced-motion") != null) {
+ return std.mem.indexOf(u8, trimmed, "no-preference") != null;
+ }
+
+ // "(min-width: Npx)" -> compare against screen width
+ if (std.mem.indexOf(u8, trimmed, "min-width")) |_| {
+ if (parsePxValue(trimmed)) |px| {
+ return screen_width >= px;
+ }
+ }
+
+ // "(max-width: Npx)"
+ if (std.mem.indexOf(u8, trimmed, "max-width")) |_| {
+ if (parsePxValue(trimmed)) |px| {
+ return screen_width <= px;
+ }
+ }
+
+ // "screen", "all", "(color)" -> true
+ if (std.mem.eql(u8, trimmed, "screen") or
+ std.mem.eql(u8, trimmed, "all") or
+ std.mem.eql(u8, trimmed, "(color)"))
+ {
+ return true;
+ }
+
+ // "print" -> false
+ if (std.mem.eql(u8, trimmed, "print")) return false;
+
+ // Default: true (most queries should match for a desktop browser)
+ return true;
+}
+
+fn parsePxValue(query: []const u8) ?u32 {
+ // Find a number followed by "px" in the query
+ var i: usize = 0;
+ while (i < query.len) : (i += 1) {
+ if (std.ascii.isDigit(query[i])) {
+ var end = i;
+ while (end < query.len and std.ascii.isDigit(query[end])) : (end += 1) {}
+ if (end + 2 <= query.len and std.mem.eql(u8, query[end .. end + 2], "px")) {
+ return std.fmt.parseInt(u32, query[i..end], 10) catch null;
+ }
+ }
+ }
+ return null;
+}
+
pub fn addListener(_: *const MediaQueryList, _: js.Function) void {}
pub fn removeListener(_: *const MediaQueryList, _: js.Function) void {}
@@ -51,7 +114,7 @@ pub const JsApi = struct {
};
pub const media = bridge.accessor(MediaQueryList.getMedia, null, .{});
- pub const matches = bridge.property(false, .{ .template = false, .readonly = true });
+ pub const matches = bridge.accessor(MediaQueryList.getMatches, null, .{});
pub const addListener = bridge.function(MediaQueryList.addListener, .{ .noop = true });
pub const removeListener = bridge.function(MediaQueryList.removeListener, .{ .noop = true });
};
diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig
index 70da796bab..b740b52d70 100644
--- a/src/browser/webapi/element/html/Canvas.zig
+++ b/src/browser/webapi/element/html/Canvas.zig
@@ -26,6 +26,7 @@ const HtmlElement = @import("../Html.zig");
const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D.zig");
const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig");
const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig");
+const png_encode = @import("../../canvas/png_encode.zig");
const Canvas = @This();
_proto: *HtmlElement,
@@ -103,6 +104,28 @@ pub fn transferControlToOffscreen(self: *Canvas, page: *Page) !*OffscreenCanvas
return OffscreenCanvas.constructor(width, height, page);
}
+/// Returns a data URL containing a representation of the canvas image.
+pub fn toDataURL(self: *Canvas, _: ?[]const u8, page: *Page) []const u8 {
+ if (self._cached) |cached| {
+ switch (cached) {
+ .@"2d" => |ctx| {
+ if (ctx.getPixelBuffer()) |pixels| {
+ return png_encode.encodeToDataURL(
+ page.call_arena,
+ pixels,
+ self.getWidth(),
+ self.getHeight(),
+ ) catch return fallback_png;
+ }
+ },
+ .webgl => {},
+ }
+ }
+ return fallback_png;
+}
+
+const fallback_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
pub const JsApi = struct {
pub const bridge = js.Bridge(Canvas);
@@ -116,4 +139,5 @@ pub const JsApi = struct {
pub const height = bridge.accessor(Canvas.getHeight, Canvas.setHeight, .{});
pub const getContext = bridge.function(Canvas.getContext, .{});
pub const transferControlToOffscreen = bridge.function(Canvas.transferControlToOffscreen, .{});
+ pub const toDataURL = bridge.function(Canvas.toDataURL, .{});
};
diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig
index e596f4aced..284ef08883 100644
--- a/src/browser/webapi/element/html/IFrame.zig
+++ b/src/browser/webapi/element/html/IFrame.zig
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Window = @import("../../Window.zig");
@@ -57,9 +58,10 @@ pub fn setSrc(self: *IFrame, src: []const u8, page: *Page) !void {
const element = self.asElement();
try element.setAttributeSafe(comptime .wrap("src"), .wrap(src), page);
self._src = element.getAttributeSafe(comptime .wrap("src")) orelse unreachable;
+ // Only trigger navigation if the iframe is connected to the document.
+ // For detached iframes (e.g., inside a shadow root that hasn't been
+ // appended to the document yet), store the src and wait for connection.
if (element.asNode().isConnected()) {
- // unlike script, an iframe is reloaded every time the src is set
- // even if it's set to the same URL.
self._executed = false;
try page.iframeAddedCallback(self);
}
@@ -88,10 +90,25 @@ pub const JsApi = struct {
pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{});
};
+const String = @import("../../../../string.zig").String;
+
pub const Build = struct {
pub fn complete(node: *Node, _: *Page) !void {
const self = node.as(IFrame);
const element = self.asElement();
self._src = element.getAttributeSafe(comptime .wrap("src")) orelse "";
}
+
+ /// Handle attribute changes — triggers iframe navigation when src is set via setAttribute.
+ pub fn attributeChange(element: *Element, name: String, _: String, page: *Page) !void {
+ if (!name.eql(comptime .wrap("src"))) return;
+ const self = element.as(IFrame);
+ const new_src = element.getAttributeSafe(comptime .wrap("src")) orelse "";
+ if (std.mem.eql(u8, new_src, self._src)) return;
+ self._src = new_src;
+ if (element.asNode().isConnected()) {
+ self._executed = false;
+ try page.iframeAddedCallback(self);
+ }
+ }
};
diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig
index 03530400fc..8ecb0e5319 100644
--- a/src/browser/webapi/event/MessageEvent.zig
+++ b/src/browser/webapi/event/MessageEvent.zig
@@ -100,8 +100,11 @@ pub fn getOrigin(self: *const MessageEvent) []const u8 {
return self._origin;
}
-pub fn getSource(self: *const MessageEvent) ?*Window {
- return self._source;
+/// Return source as CrossOriginWindow — this allows postMessage on event.source
+/// regardless of origin (matching the HTML spec's WindowProxy behavior).
+pub fn getSource(self: *const MessageEvent) ?*Window.CrossOriginWindow {
+ const source = self._source orelse return null;
+ return &source._cross_origin_wrapper;
}
pub const JsApi = struct {
diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig
index cf3cdd7db8..cc20b028f5 100644
--- a/src/cdp/domains/page.zig
+++ b/src/cdp/domains/page.zig
@@ -531,6 +531,9 @@ pub fn pageNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const No
);
}
+ // StealthPanda: stealth inject now runs at V8 context creation (Env.zig)
+ // to ensure it executes before ANY scripts including async ones.
+
// Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument.
// Must run after the execution context is created but before the client
// receives frameNavigated/loadEventFired so polyfills are available for
diff --git a/src/cdp/domains/stealth_inject.zig b/src/cdp/domains/stealth_inject.zig
new file mode 100644
index 0000000000..5b75a3a8fe
--- /dev/null
+++ b/src/cdp/domains/stealth_inject.zig
@@ -0,0 +1,111 @@
+// StealthPanda: JavaScript stealth injection script.
+// Injected before page scripts to patch common bot detection vectors.
+
+pub const script: [:0]const u8 =
+ \\// --- StealthPanda anti-detection patches ---
+ \\
+ \\// 1. Lock navigator.webdriver to false (prevent overwrite detection)
+ \\Object.defineProperty(navigator, 'webdriver', {
+ \\ get: () => false,
+ \\ configurable: false,
+ \\ enumerable: true
+ \\});
+ \\
+ \\// 2. Remove cdc_ (ChromeDriver) artifacts from window
+ \\(function() {
+ \\ var keys = Object.keys(window);
+ \\ for (var i = 0; i < keys.length; i++) {
+ \\ if (keys[i].match(/^cdc_|^__webdriver/)) {
+ \\ try { delete window[keys[i]]; } catch(e) {}
+ \\ }
+ \\ }
+ \\})();
+ \\
+ \\// 3. Patch Permissions.query to return 'prompt' for notifications
+ \\// (headless browsers often return 'denied' which is a detection signal)
+ \\if (navigator.permissions && navigator.permissions.query) {
+ \\ var origQuery = navigator.permissions.query.bind(navigator.permissions);
+ \\ Object.defineProperty(navigator.permissions, 'query', {
+ \\ value: function(desc) {
+ \\ if (desc && desc.name === 'notifications') {
+ \\ return Promise.resolve({ state: 'prompt', onchange: null });
+ \\ }
+ \\ return origQuery(desc);
+ \\ },
+ \\ writable: true,
+ \\ configurable: true
+ \\ });
+ \\}
+ \\
+ \\// 4. Ensure chrome.runtime has expected shape
+ \\if (window.chrome) {
+ \\ if (!window.chrome.runtime) {
+ \\ window.chrome.runtime = {};
+ \\ }
+ \\ // Chrome runtime should not have onConnect in content scripts
+ \\ // but should exist in extensions
+ \\}
+ \\
+ \\// 5. Deferred iframe.src trigger for shadow DOM iframes
+ \\// When an iframe's src is set via setAttribute while it's in a detached
+ \\// shadow DOM, defer the .src property assignment until the next microtask
+ \\// (by which time the shadow host should be connected to the document).
+ \\(function() {
+ \\ var origSetAttribute = Element.prototype.setAttribute;
+ \\ Element.prototype.setAttribute = function(name, value) {
+ \\ var result = origSetAttribute.call(this, name, value);
+ \\ if (this.tagName === 'IFRAME' && name === 'src' && value) {
+ \\ var iframe = this;
+ \\ // Defer with multiple delays to ensure shadow host is connected
+ \\ var triggered = false;
+ \\ var triggerSrc = function() {
+ \\ if (triggered) return;
+ \\ var alive = !!iframe;
+ \\ var connected = alive && iframe.isConnected;
+ \\ var hasSrc = alive && iframe.getAttribute('src');
+ \\ if (connected) {
+ \\ triggered = true;
+ \\ try { iframe.src = hasSrc; } catch(e) {}
+ \\ }
+ \\ };
+ \\ setTimeout(triggerSrc, 50);
+ \\ setTimeout(triggerSrc, 500);
+ \\ }
+ \\ return result;
+ \\ };
+ \\})();
+ \\
+ \\// 6. Intercept Turnstile handler to inject logging into requestExtraParams
+ \\(function() {
+ \\ var _ael = EventTarget.prototype.addEventListener;
+ \\ EventTarget.prototype.addEventListener = function(type, fn, opts) {
+ \\ if (type === 'message' && fn && fn.toString().indexOf('widgetMap') !== -1) {
+ \\ var origFn = fn;
+ \\ var wrapped = function(e) {
+ \\ try {
+ \\ origFn.call(this, e);
+ \\ } catch(ex) {}
+ \\ };
+ \\ return _ael.call(this, type, wrapped, opts);
+ \\ }
+ \\ return _ael.call(this, type, fn, opts);
+ \\ };
+ \\})();
+ \\
+ \\// 7. Block unsupported_browser reject in PARENT and IFRAME contexts
+ \\window.addEventListener('message', function(e) {
+ \\ if (e.data && e.data.source === 'cloudflare-challenge' &&
+ \\ e.data.event === 'reject' && e.data.reason === 'unsupported_browser') {
+ \\ e.stopImmediatePropagation();
+ \\ }
+ \\}, true);
+ \\if (window.parent && window.parent !== window) {
+ \\ try {
+ \\ var _opm = window.parent.postMessage;
+ \\ window.parent.postMessage = function(msg, o) {
+ \\ if (msg && msg.event === 'reject' && msg.reason === 'unsupported_browser') return;
+ \\ return _opm.apply(this, arguments);
+ \\ };
+ \\ } catch(e) {}
+ \\}
+;
diff --git a/src/network/TlsProfile.zig b/src/network/TlsProfile.zig
new file mode 100644
index 0000000000..5f7fffcf32
--- /dev/null
+++ b/src/network/TlsProfile.zig
@@ -0,0 +1,65 @@
+// StealthPanda: TLS fingerprint profiles.
+// Cloudflare and other bot detection systems fingerprint the TLS ClientHello
+// (JA3/JA4) to verify the claimed browser identity. These profiles configure
+// libcurl to match real browser TLS handshakes.
+
+const libcurl = @import("../sys/libcurl.zig");
+
+pub const TlsProfile = struct {
+ name: []const u8,
+ /// TLS 1.2 cipher suite ordering (CURLOPT_SSL_CIPHER_LIST)
+ cipher_list: [:0]const u8,
+ /// TLS 1.3 cipher suite ordering (CURLOPT_TLS13_CIPHERS)
+ tls13_ciphers: [:0]const u8,
+ /// Elliptic curve ordering (CURLOPT_SSL_EC_CURVES)
+ ec_curves: [:0]const u8,
+ /// HTTP version preference (CURL_HTTP_VERSION_*)
+ http_version: c_long,
+
+ pub fn apply(self: *const TlsProfile, easy: *libcurl.Curl) !void {
+ libcurl.curl_easy_setopt(easy, .ssl_cipher_list, self.cipher_list.ptr) catch {};
+ libcurl.curl_easy_setopt(easy, .tls13_ciphers, self.tls13_ciphers.ptr) catch {};
+ libcurl.curl_easy_setopt(easy, .ssl_ec_curves, self.ec_curves.ptr) catch {};
+ libcurl.curl_easy_setopt(easy, .http_version, self.http_version) catch {};
+ }
+
+ /// Chrome 131 TLS fingerprint profile.
+ /// Cipher suites and curves match Chrome's ClientHello.
+ pub const chrome_131: TlsProfile = .{
+ .name = "chrome_131",
+ .cipher_list = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" ++
+ "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" ++
+ "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" ++
+ "ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:" ++
+ "AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA",
+ .tls13_ciphers = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
+ .ec_curves = "X25519:P-256:P-384",
+ // CURL_HTTP_VERSION_2TLS = 4: HTTP/2 for HTTPS, HTTP/1.1 for HTTP
+ .http_version = 4,
+ };
+
+ /// Firefox 133 TLS fingerprint profile.
+ pub const firefox_133: TlsProfile = .{
+ .name = "firefox_133",
+ .cipher_list = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" ++
+ "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" ++
+ "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" ++
+ "ECDHE-ECDSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:" ++
+ "ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:" ++
+ "AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA",
+ .tls13_ciphers = "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384",
+ .ec_curves = "X25519:P-256:P-384:P-521",
+ // CURL_HTTP_VERSION_2TLS = 4
+ .http_version = 4,
+ };
+
+ /// Default profile — uses Chrome 131.
+ pub const default = chrome_131;
+
+ pub fn fromName(name: []const u8) ?*const TlsProfile {
+ const std = @import("std");
+ if (std.mem.eql(u8, name, "chrome") or std.mem.eql(u8, name, "chrome_131")) return &chrome_131;
+ if (std.mem.eql(u8, name, "firefox") or std.mem.eql(u8, name, "firefox_133")) return &firefox_133;
+ return null;
+ }
+};
diff --git a/src/network/http.zig b/src/network/http.zig
index 2bfabac07f..36b64229ca 100644
--- a/src/network/http.zig
+++ b/src/network/http.zig
@@ -63,6 +63,15 @@ pub const Headers = struct {
return .{ .headers = header_list };
}
+ /// Initialize headers with User-Agent and Client Hints (Chrome-like).
+ pub fn initWithClientHints(http_headers: *const Config.HttpHeaders) !Headers {
+ var headers = try init(http_headers.user_agent_header);
+ try headers.add(http_headers.sec_ch_ua_header);
+ try headers.add(http_headers.sec_ch_ua_mobile_header);
+ try headers.add(http_headers.sec_ch_ua_platform_header);
+ return headers;
+ }
+
pub fn deinit(self: *const Headers) void {
if (self.headers) |hdr| {
libcurl.curl_slist_free_all(hdr);
@@ -349,6 +358,10 @@ pub const Connection = struct {
}
}
+ // TLS fingerprint profile (Chrome-like by default)
+ const tls_profile = config.tlsProfile();
+ try tls_profile.apply(self._easy);
+
// debug
if (comptime ENABLE_DEBUG) {
try libcurl.curl_easy_setopt(self._easy, .verbose, true);
diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig
index 0e2defe307..e7b8b9dcf7 100644
--- a/src/sys/libcurl.zig
+++ b/src/sys/libcurl.zig
@@ -167,6 +167,12 @@ pub const CurlOption = enum(c.CURLoption) {
header_function = c.CURLOPT_HEADERFUNCTION,
write_data = c.CURLOPT_WRITEDATA,
write_function = c.CURLOPT_WRITEFUNCTION,
+
+ // TLS fingerprint options
+ ssl_cipher_list = c.CURLOPT_SSL_CIPHER_LIST,
+ tls13_ciphers = c.CURLOPT_TLS13_CIPHERS,
+ ssl_ec_curves = c.CURLOPT_SSL_EC_CURVES,
+ http_version = c.CURLOPT_HTTP_VERSION,
};
pub const CurlMOption = enum(c.CURLMoption) {
@@ -551,6 +557,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.max_redirs,
.follow_location,
.post_field_size,
+ .http_version,
=> blk: {
const n: c_long = switch (@typeInfo(@TypeOf(value))) {
.comptime_int, .int => @intCast(value),
@@ -568,6 +575,9 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.user_pwd,
.proxy_user_pwd,
.copy_post_fields,
+ .ssl_cipher_list,
+ .tls13_ciphers,
+ .ssl_ec_curves,
=> blk: {
const s: ?[*]const u8 = value;
break :blk c.curl_easy_setopt(easy, opt, s);
diff --git a/test_botdetect.js b/test_botdetect.js
new file mode 100644
index 0000000000..9afaab5b39
--- /dev/null
+++ b/test_botdetect.js
@@ -0,0 +1,107 @@
+// Test StealthPanda against real bot detection pages
+const WebSocket = require('ws');
+
+const CDP_URL = 'ws://127.0.0.1:9222';
+let msgId = 1;
+
+async function createSession(ws) {
+ const pending = new Map();
+
+ ws.on('message', (data) => {
+ const msg = JSON.parse(data);
+ if (msg.id !== undefined && pending.has(msg.id)) {
+ pending.get(msg.id)(msg);
+ }
+ });
+
+ function send(method, params = {}) {
+ return new Promise((resolve, reject) => {
+ const id = msgId++;
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 20000);
+ pending.set(id, (msg) => { clearTimeout(timer); pending.delete(id); resolve(msg); });
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+ }
+
+ await send('Target.createBrowserContext');
+ await send('Target.createTarget', { url: 'about:blank', browserContextId: 'BID-1' });
+ await send('Target.attachToTarget', { targetId: 'FID-0000000001', flatten: true });
+
+ return { send };
+}
+
+async function evaluate(session, expr) {
+ const r = await session.send('Runtime.evaluate', {
+ expression: `(() => { try { return String(${expr}); } catch(e) { return 'ERR:' + e.message; } })()`,
+ returnByValue: true,
+ });
+ return r.result?.result?.value ?? 'N/A';
+}
+
+async function main() {
+ const ws = new WebSocket(CDP_URL);
+ await new Promise(r => ws.on('open', r));
+ const session = await createSession(ws);
+
+ // ===== Test 1: bot.sannysoft.com =====
+ console.log('=== Testing bot.sannysoft.com ===\n');
+ console.log('Navigating...');
+ await session.send('Page.navigate', { url: 'https://bot.sannysoft.com/' });
+ await new Promise(r => setTimeout(r, 8000));
+
+ // SannySoft outputs test results in a table. Let's grab key results.
+ const sannyTests = [
+ ['User Agent', 'document.querySelector("#res-userAgent")?.className || "missing"'],
+ ['WebDriver', 'document.querySelector("#res-webdriver")?.className || "missing"'],
+ ['Chrome', 'document.querySelector("#res-chrome")?.className || "missing"'],
+ ['Permissions', 'document.querySelector("#res-permissions")?.className || "missing"'],
+ ['Plugins Length', 'document.querySelector("#res-pluginsLength")?.className || "missing"'],
+ ['Languages', 'document.querySelector("#res-languages")?.className || "missing"'],
+ ['WebGL Vendor', 'document.querySelector("#res-webglVendor")?.className || "missing"'],
+ ['WebGL Renderer', 'document.querySelector("#res-webglRenderer")?.className || "missing"'],
+ ['Hairline', 'document.querySelector("#res-hairline")?.className || "missing"'],
+ ['Broken Image', 'document.querySelector("#res-brokenImage")?.className || "missing"'],
+ ];
+
+ for (const [name, expr] of sannyTests) {
+ const val = await evaluate(session, expr);
+ const icon = val.includes('passed') ? '✓' : val.includes('failed') ? '✗' : val.includes('warn') ? '⚠' : '?';
+ console.log(`${icon} ${name}: ${val}`);
+ }
+
+ // Also get the actual values that sannysoft sees
+ console.log('\n--- Detected Values ---');
+ const detectedValues = [
+ ['User-Agent', 'navigator.userAgent.substring(0, 80)'],
+ ['WebDriver', 'navigator.webdriver'],
+ ['Languages', 'navigator.languages.join(",")'],
+ ['Plugins', 'navigator.plugins.length'],
+ ['Chrome obj', 'typeof window.chrome'],
+ ['Permissions', 'typeof navigator.permissions?.query'],
+ ['Connection', 'typeof navigator.connection'],
+ ['DeviceMemory', 'navigator.deviceMemory'],
+ ['HW Concurrency', 'navigator.hardwareConcurrency'],
+ ['WebGL Vendor', '(()=>{try{var c=document.createElement("canvas");var g=c.getContext("webgl");var e=g.getExtension("WEBGL_debug_renderer_info");return g.getParameter(e.UNMASKED_VENDOR_WEBGL)}catch(x){return "ERR:"+x.message}})()'],
+ ['WebGL Renderer', '(()=>{try{var c=document.createElement("canvas");var g=c.getContext("webgl");var e=g.getExtension("WEBGL_debug_renderer_info");return g.getParameter(e.UNMASKED_RENDERER_WEBGL)}catch(x){return "ERR:"+x.message}})()'],
+ ['Canvas FP', '(()=>{try{var c=document.createElement("canvas");c.width=200;c.height=50;var x=c.getContext("2d");x.fillStyle="red";x.fillRect(10,10,50,50);x.fillStyle="blue";x.font="14px Arial";x.fillText("StealthPanda",60,35);return c.toDataURL().length}catch(e){return "ERR:"+e.message}})()'],
+ ['screen.width', 'screen.width'],
+ ['screen.height', 'screen.height'],
+ ['colorDepth', 'screen.colorDepth'],
+ ['AudioContext', 'typeof AudioContext'],
+ ['Notification', 'typeof Notification'],
+ ['MediaDevices', 'typeof navigator.mediaDevices'],
+ ['SpeechSynth', 'typeof window.speechSynthesis'],
+ ['window.chrome', 'typeof window.chrome'],
+ ];
+
+ for (const [name, expr] of detectedValues) {
+ const val = await evaluate(session, expr);
+ console.log(` ${name}: ${val}`);
+ }
+
+ console.log('\nDone.');
+ ws.close();
+ process.exit(0);
+}
+
+main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
diff --git a/test_fingerprint.js b/test_fingerprint.js
new file mode 100644
index 0000000000..2ac96b1c3d
--- /dev/null
+++ b/test_fingerprint.js
@@ -0,0 +1,129 @@
+// Test script to verify StealthPanda fingerprint changes via CDP
+const WebSocket = require('ws');
+
+const CDP_URL = 'ws://127.0.0.1:9222';
+let msgId = 1;
+
+async function main() {
+ const ws = new WebSocket(CDP_URL);
+ const pending = new Map();
+ const events = [];
+
+ ws.on('message', (data) => {
+ const msg = JSON.parse(data);
+ if (msg.id !== undefined && pending.has(msg.id)) {
+ pending.get(msg.id)(msg);
+ } else {
+ events.push(msg);
+ }
+ });
+
+ function send(method, params = {}) {
+ return new Promise((resolve, reject) => {
+ const id = msgId++;
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 15000);
+ pending.set(id, (msg) => { clearTimeout(timer); pending.delete(id); resolve(msg); });
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+ }
+
+ await new Promise(r => ws.on('open', r));
+
+ // 1. Create browser context
+ console.log('Creating browser context...');
+ const ctxResult = await send('Target.createBrowserContext');
+ console.log('Browser context:', JSON.stringify(ctxResult));
+ const browserContextId = ctxResult.result?.browserContextId;
+
+ // 2. Create target (page)
+ console.log('Creating target...');
+ const targetResult = await send('Target.createTarget', {
+ url: 'about:blank',
+ browserContextId,
+ });
+ console.log('Target:', JSON.stringify(targetResult));
+ const targetId = targetResult.result?.targetId;
+
+ // 3. Attach to target
+ console.log('Attaching to target...');
+ const attachResult = await send('Target.attachToTarget', {
+ targetId,
+ flatten: true,
+ });
+ console.log('Attach:', JSON.stringify(attachResult));
+
+ // Wait for sessionId in events
+ await new Promise(r => setTimeout(r, 500));
+ const sessionId = attachResult.result?.sessionId;
+ console.log('Session ID:', sessionId);
+
+ // Helper to send session commands
+ function sessionSend(method, params = {}) {
+ return send(method, { ...params, ...(sessionId ? {} : {}) });
+ }
+
+ // 4. Navigate to example.com
+ console.log('Navigating to example.com...');
+ const navResult = await sessionSend('Page.navigate', { url: 'https://example.com' });
+ console.log('Navigation:', JSON.stringify(navResult));
+
+ // Wait for page load
+ await new Promise(r => setTimeout(r, 3000));
+
+ // 5. Evaluate JavaScript
+ const tests = [
+ ['navigator.userAgent', 'Chrome UA string'],
+ ['navigator.vendor', '"Google Inc."'],
+ ['navigator.appVersion', 'Starts with 5.0'],
+ ['navigator.plugins.length', '5'],
+ ['navigator.plugins[0] ? navigator.plugins[0].name : "null"', '"PDF Viewer"'],
+ ['navigator.plugins[1] ? navigator.plugins[1].name : "null"', '"Chrome PDF Viewer"'],
+ ['navigator.webdriver', 'false'],
+ ['navigator.platform', '"Linux x86_64"'],
+ ['navigator.hardwareConcurrency', '4'],
+ ['navigator.deviceMemory', '8'],
+ ['navigator.language', '"en-US"'],
+ ['window.innerWidth', '1920'],
+ ['window.innerHeight', '1080'],
+ ['window.screen.width', '1920'],
+ ['window.screen.height', '1080'],
+ ['window.screen.availHeight', '1040'],
+ ['window.screen.colorDepth', '24'],
+ ['typeof AudioContext', '"function"'],
+ ['typeof document.createElement("canvas").toDataURL', '"function"'],
+ ['document.createElement("canvas").toDataURL().substring(0, 22)', '"data:image/png;base64"'],
+ ];
+
+ console.log('\n=== StealthPanda Fingerprint Test ===\n');
+
+ let passed = 0;
+ let failed = 0;
+
+ for (const [expr, expected] of tests) {
+ const result = await sessionSend('Runtime.evaluate', {
+ expression: `(() => { try { return String(${expr}); } catch(e) { return 'ERROR: ' + e.message; } })()`,
+ returnByValue: true,
+ });
+
+ let value = 'N/A';
+ if (result.result?.result?.value !== undefined) {
+ value = result.result.result.value;
+ } else if (result.error) {
+ value = `CDP_ERROR: ${result.error.message}`;
+ }
+
+ const isError = String(value).startsWith('ERROR') || String(value).startsWith('CDP_ERROR');
+ const pass = !isError && value !== 'undefined' && value !== 'N/A';
+
+ if (pass) passed++; else failed++;
+ console.log(`${pass ? '✓' : '✗'} ${expr}`);
+ console.log(` → ${value} (expected: ${expected})\n`);
+ }
+
+ console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
+
+ ws.close();
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });